From 398661d3406743206fb71216fd85e1075938ba75 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:55:10 +0200 Subject: [PATCH] feat(settings): add location sharing controls to Personalization Adds Location section to Settings Personalization tab: opt-in switch, manual text input, and device-geolocation button with client-side reverse-geocoding (BigDataCloud, admin-overridable endpoint). Access is gated by startupConfig.location.enabled; enabling it makes the Personalization tab visible even without memory opt-out access. --- client/src/components/Nav/Settings.tsx | 4 +- .../Nav/SettingsTabs/Personalization.tsx | 149 ++++++++++++++++-- .../__tests__/Personalization.spec.tsx | 66 ++++++++ client/src/hooks/usePersonalizationAccess.ts | 6 +- client/src/locales/en/translation.json | 8 + client/src/utils/geocode.ts | 59 +++++++ 6 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 client/src/components/Nav/SettingsTabs/__tests__/Personalization.spec.tsx create mode 100644 client/src/utils/geocode.ts diff --git a/client/src/components/Nav/Settings.tsx b/client/src/components/Nav/Settings.tsx index f5574d5616..2a73e7317c 100644 --- a/client/src/components/Nav/Settings.tsx +++ b/client/src/components/Nav/Settings.tsx @@ -34,7 +34,8 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { const localize = useLocalize(); const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL); const tabRefs = useRef({}); - const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess(); + const { hasAnyPersonalizationFeature, hasMemoryOptOut, hasLocationSharing } = + usePersonalizationAccess(); const aboutEnabled = startupConfig?.interface?.buildInfo !== false; useEffect(() => { @@ -254,6 +255,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { diff --git a/client/src/components/Nav/SettingsTabs/Personalization.tsx b/client/src/components/Nav/SettingsTabs/Personalization.tsx index f9e43dc6f5..7d5211205f 100644 --- a/client/src/components/Nav/SettingsTabs/Personalization.tsx +++ b/client/src/components/Nav/SettingsTabs/Personalization.tsx @@ -1,51 +1,125 @@ import { useState, useEffect } from 'react'; -import { Switch, useToastContext } from '@librechat/client'; -import { useGetUserQuery, useUpdateMemoryPreferencesMutation } from '~/data-provider'; +import { Switch, Input, useToastContext } from '@librechat/client'; +import type { TUserLocation } from 'librechat-data-provider'; +import { + useGetUserQuery, + useGetStartupConfig, + useUpdateUserLocationMutation, + useUpdateMemoryPreferencesMutation, +} from '~/data-provider'; +import { reverseGeocode, getCurrentPosition } from '~/utils/geocode'; import { useLocalize } from '~/hooks'; interface PersonalizationProps { hasMemoryOptOut: boolean; + hasLocationSharing: boolean; hasAnyPersonalizationFeature: boolean; } export default function Personalization({ hasMemoryOptOut, + hasLocationSharing, hasAnyPersonalizationFeature, }: PersonalizationProps) { const localize = useLocalize(); const { showToast } = useToastContext(); const { data: user } = useGetUserQuery(); + const { data: startupConfig } = useGetStartupConfig(); + const [referenceSavedMemories, setReferenceSavedMemories] = useState(true); + const [locationEnabled, setLocationEnabled] = useState(false); + const [manualLocation, setManualLocation] = useState(''); + const [detecting, setDetecting] = useState(false); const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({ onSuccess: () => { - showToast({ - message: localize('com_ui_preferences_updated'), - status: 'success', - }); + showToast({ message: localize('com_ui_preferences_updated'), status: 'success' }); }, onError: () => { - showToast({ - message: localize('com_ui_error_updating_preferences'), - status: 'error', - }); - // Revert the toggle on error + showToast({ message: localize('com_ui_error_updating_preferences'), status: 'error' }); setReferenceSavedMemories((prev) => !prev); }, }); - // Initialize state from user data + const updateLocationMutation = useUpdateUserLocationMutation({ + onSuccess: () => { + showToast({ message: localize('com_ui_preferences_updated'), status: 'success' }); + }, + onError: () => { + showToast({ message: localize('com_ui_error_updating_preferences'), status: 'error' }); + }, + }); + useEffect(() => { if (user?.personalization?.memories !== undefined) { setReferenceSavedMemories(user.personalization.memories); } }, [user?.personalization?.memories]); + useEffect(() => { + const loc = user?.personalization?.location; + if (loc) { + setLocationEnabled(loc.enabled ?? false); + setManualLocation(loc.manual ?? ''); + } + }, [user?.personalization?.location]); + const handleMemoryToggle = (checked: boolean) => { setReferenceSavedMemories(checked); updateMemoryPreferencesMutation.mutate({ memories: checked }); }; + const persistLocation = (payload: TUserLocation) => updateLocationMutation.mutate(payload); + + const handleLocationToggle = (checked: boolean) => { + setLocationEnabled(checked); + persistLocation({ enabled: checked, source: 'manual', manual: manualLocation || undefined }); + }; + + const handleManualBlur = () => { + if (!locationEnabled && !manualLocation) { + return; + } + persistLocation({ + enabled: locationEnabled, + source: 'manual', + manual: manualLocation || undefined, + }); + }; + + const handleUseDeviceLocation = async () => { + setDetecting(true); + try { + const position = await getCurrentPosition(); + const resolved = await reverseGeocode( + position.coords.latitude, + position.coords.longitude, + startupConfig?.location?.geocoder?.endpoint, + ); + setLocationEnabled(true); + persistLocation({ + enabled: true, + source: 'auto', + place: resolved.place, + coordinates: resolved.coordinates, + timezone: resolved.timezone, + }); + } catch (error) { + const denied = + typeof GeolocationPositionError !== 'undefined' && + error instanceof GeolocationPositionError && + error.code === error.PERMISSION_DENIED; + showToast({ + message: localize( + denied ? 'com_ui_location_permission_denied' : 'com_ui_location_unavailable', + ), + status: 'warning', + }); + } finally { + setDetecting(false); + } + }; + if (!hasAnyPersonalizationFeature) { return (
@@ -56,7 +130,6 @@ export default function Personalization({ return (
- {/* Memory Settings Section */} {hasMemoryOptOut && ( <>
@@ -85,6 +158,56 @@ export default function Personalization({
)} + + {hasLocationSharing && ( + <> +
+
{localize('com_ui_location')}
+
+ +
+
+
+ {localize('com_ui_share_location_with_agents')} +
+
+ {localize('com_ui_share_location_with_agents_description')} +
+
+ +
+ +
+ + setManualLocation(e.target.value)} + onBlur={handleManualBlur} + aria-label={localize('com_ui_set_location_manually')} + className="flex h-10 w-full px-3 py-2" + /> + +
+ + )}
); } diff --git a/client/src/components/Nav/SettingsTabs/__tests__/Personalization.spec.tsx b/client/src/components/Nav/SettingsTabs/__tests__/Personalization.spec.tsx new file mode 100644 index 0000000000..0963f7c5ba --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/__tests__/Personalization.spec.tsx @@ -0,0 +1,66 @@ +import { render, screen, fireEvent, waitFor } from 'test/layout-test-utils'; +import * as endpointQueries from '~/data-provider/Endpoints/queries'; +import * as memoriesQueries from '~/data-provider/Memories/queries'; +import * as locationQueries from '~/data-provider/Location/queries'; +import * as authQueries from '~/data-provider/Auth/queries'; +import Personalization from '../Personalization'; + +const mockMutate = jest.fn(); + +beforeEach(() => { + mockMutate.mockClear(); + + jest + .spyOn(authQueries, 'useGetUserQuery') + // @ts-expect-error partial mock + .mockReturnValue({ data: { personalization: {} } }); + + jest + .spyOn(endpointQueries, 'useGetStartupConfig') + // @ts-expect-error partial mock + .mockReturnValue({ data: { location: { enabled: true } } }); + + jest + .spyOn(memoriesQueries, 'useUpdateMemoryPreferencesMutation') + // @ts-expect-error partial mock + .mockReturnValue({ mutate: jest.fn(), isLoading: false }); + + jest + .spyOn(locationQueries, 'useUpdateUserLocationMutation') + // @ts-expect-error partial mock + .mockReturnValue({ mutate: mockMutate, isLoading: false }); + + // @ts-expect-error test stub + global.navigator.geolocation = { + getCurrentPosition: (success: PositionCallback) => + success({ coords: { latitude: 48.85, longitude: 2.35 } } as GeolocationPosition), + }; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Personalization location section', () => { + it('renders the location toggle and manual field when enabled', () => { + render( + , + ); + expect(screen.getByText('Share my location with agents')).toBeInTheDocument(); + expect(screen.getByLabelText('Set location manually')).toBeInTheDocument(); + expect(screen.getByText('Use my device location')).toBeInTheDocument(); + }); + + it('persists a manual location on blur', async () => { + render( + , + ); + const input = screen.getByLabelText('Set location manually'); + fireEvent.change(input, { target: { value: 'Tokyo, Japan' } }); + fireEvent.blur(input); + await waitFor(() => expect(mockMutate).toHaveBeenCalled()); + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ manual: 'Tokyo, Japan', source: 'manual' }), + ); + }); +}); diff --git a/client/src/hooks/usePersonalizationAccess.ts b/client/src/hooks/usePersonalizationAccess.ts index 9a419f4a63..e1069d6531 100644 --- a/client/src/hooks/usePersonalizationAccess.ts +++ b/client/src/hooks/usePersonalizationAccess.ts @@ -1,16 +1,20 @@ import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import { useGetStartupConfig } from '~/data-provider'; import useHasAccess from './Roles/useHasAccess'; export default function usePersonalizationAccess() { + const { data: startupConfig } = useGetStartupConfig(); const hasMemoryOptOut = useHasAccess({ permissionType: PermissionTypes.MEMORIES, permission: Permissions.OPT_OUT, }); - const hasAnyPersonalizationFeature = hasMemoryOptOut; + const hasLocationSharing = startupConfig?.location?.enabled === true; + const hasAnyPersonalizationFeature = hasMemoryOptOut || hasLocationSharing; return { hasMemoryOptOut, + hasLocationSharing, hasAnyPersonalizationFeature, }; } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 03f924373a..cffb19a898 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1184,6 +1184,14 @@ "com_ui_link_refreshed": "Link refreshed", "com_ui_live": "live", "com_ui_load_more": "Load more", + "com_ui_location": "Location", + "com_ui_location_detecting": "Detecting your location…", + "com_ui_location_permission_denied": "Location permission was denied. You can enter a location manually instead.", + "com_ui_location_unavailable": "Could not detect your location. You can enter one manually instead.", + "com_ui_set_location_manually": "Set location manually", + "com_ui_share_location_with_agents": "Share my location with agents", + "com_ui_share_location_with_agents_description": "Let agents use the get_location tool to read your location for language, units, and regional context", + "com_ui_use_device_location": "Use my device location", "com_ui_loading": "Loading...", "com_ui_locked": "Locked", "com_ui_logo": "{{0}} Logo", diff --git a/client/src/utils/geocode.ts b/client/src/utils/geocode.ts new file mode 100644 index 0000000000..a8114ca94f --- /dev/null +++ b/client/src/utils/geocode.ts @@ -0,0 +1,59 @@ +const DEFAULT_ENDPOINT = 'https://api.bigdatacloud.net/data/reverse-geocode-client'; + +const round = (n: number) => Math.round(n * 100) / 100; + +export interface ResolvedLocation { + place?: string; + coordinates: { latitude: number; longitude: number }; + timezone?: string; +} + +/** + * Reverse-geocodes coordinates client-side via the configured (CORS) endpoint. + * Always returns rounded coordinates + timezone; `place` is omitted on failure. + */ +export async function reverseGeocode( + latitude: number, + longitude: number, + endpoint: string = DEFAULT_ENDPOINT, +): Promise { + const coordinates = { latitude: round(latitude), longitude: round(longitude) }; + let timezone: string | undefined; + try { + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + timezone = undefined; + } + + try { + const url = `${endpoint}?latitude=${latitude}&longitude=${longitude}&localityLanguage=en`; + const response = await fetch(url); + if (!response.ok) { + return { coordinates, timezone }; + } + const data = await response.json(); + const place = [data.city || data.locality, data.principalSubdivision, data.countryName] + .filter((part: unknown): part is string => typeof part === 'string' && part.length > 0) + .join(', '); + return { place: place || undefined, coordinates, timezone }; + } catch { + return { coordinates, timezone }; + } +} + +/** + * Promisified navigator.geolocation.getCurrentPosition. + */ +export function getCurrentPosition(): Promise { + return new Promise((resolve, reject) => { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + reject(new Error('Geolocation is not supported')); + return; + } + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: false, + timeout: 10000, + maximumAge: 600000, + }); + }); +}