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.
This commit is contained in:
Marco Beretta 2026-06-15 18:55:10 +02:00
parent a4f095a856
commit 398661d340
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
6 changed files with 277 additions and 15 deletions

View file

@ -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) {
<Tabs.Content value={SettingsTabValues.PERSONALIZATION} tabIndex={-1}>
<Personalization
hasMemoryOptOut={hasMemoryOptOut}
hasLocationSharing={hasLocationSharing}
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
/>
</Tabs.Content>

View file

@ -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 (
<div className="flex flex-col gap-3 text-sm text-text-primary">
@ -56,7 +130,6 @@ export default function Personalization({
return (
<div className="flex flex-col gap-3 text-sm text-text-primary">
{/* Memory Settings Section */}
{hasMemoryOptOut && (
<>
<div className="border-b border-border-medium pb-3">
@ -85,6 +158,56 @@ export default function Personalization({
</div>
</>
)}
{hasLocationSharing && (
<>
<div className="border-b border-border-medium pb-3 pt-2">
<div className="text-base font-semibold">{localize('com_ui_location')}</div>
</div>
<div className="flex items-center justify-between">
<div>
<div id="share-location-label" className="flex items-center gap-2">
{localize('com_ui_share_location_with_agents')}
</div>
<div id="share-location-description" className="mt-1 text-xs text-text-secondary">
{localize('com_ui_share_location_with_agents_description')}
</div>
</div>
<Switch
checked={locationEnabled}
onCheckedChange={handleLocationToggle}
disabled={updateLocationMutation.isLoading}
aria-labelledby="share-location-label"
aria-describedby="share-location-description"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="manual-location-input" className="text-xs text-text-secondary">
{localize('com_ui_set_location_manually')}
</label>
<Input
id="manual-location-input"
value={manualLocation}
onChange={(e) => setManualLocation(e.target.value)}
onBlur={handleManualBlur}
aria-label={localize('com_ui_set_location_manually')}
className="flex h-10 w-full px-3 py-2"
/>
<button
type="button"
onClick={handleUseDeviceLocation}
disabled={detecting}
className="self-start rounded-md border border-border-medium px-3 py-1.5 text-xs text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
{detecting
? localize('com_ui_location_detecting')
: localize('com_ui_use_device_location')}
</button>
</div>
</>
)}
</div>
);
}

View file

@ -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(
<Personalization hasMemoryOptOut={false} hasLocationSharing hasAnyPersonalizationFeature />,
);
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(
<Personalization hasMemoryOptOut={false} hasLocationSharing hasAnyPersonalizationFeature />,
);
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' }),
);
});
});

View file

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

View file

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

View file

@ -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<ResolvedLocation> {
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<GeolocationPosition> {
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,
});
});
}