mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
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:
parent
a4f095a856
commit
398661d340
6 changed files with 277 additions and 15 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
59
client/src/utils/geocode.ts
Normal file
59
client/src/utils/geocode.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue