mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix(tools): register get_location in availableTools and refine location UX
This commit is contained in:
parent
398661d340
commit
f2379f7327
9 changed files with 110 additions and 36 deletions
|
|
@ -12,7 +12,7 @@ const TraversaalSearch = require('./structured/TraversaalSearch');
|
|||
const createOpenAIImageTools = require('./structured/OpenAIImageTools');
|
||||
const TavilySearchResults = require('./structured/TavilySearchResults');
|
||||
const createGeminiImageTool = require('./structured/GeminiImageGen');
|
||||
const createLocationTool = require('./structured/GetLocation');
|
||||
const GetLocation = require('./structured/GetLocation');
|
||||
|
||||
module.exports = {
|
||||
...manifest,
|
||||
|
|
@ -28,5 +28,5 @@ module.exports = {
|
|||
TavilySearchResults,
|
||||
createOpenAIImageTools,
|
||||
createGeminiImageTool,
|
||||
createLocationTool,
|
||||
GetLocation,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@
|
|||
"name": "Get Location",
|
||||
"pluginKey": "get_location",
|
||||
"description": "Returns the user's shared location (place, coordinates, timezone) so the assistant can tailor language, units, and regional context.",
|
||||
"icon": "assets/google-search.svg",
|
||||
"icon": "assets/logo.svg",
|
||||
"authConfig": []
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const { tool } = require('@librechat/agents/langchain/tools');
|
||||
const { Tool } = require('@librechat/agents/langchain/tools');
|
||||
const { formatLocationToolResult } = require('@librechat/api');
|
||||
|
||||
const locationSchema = {
|
||||
|
|
@ -8,22 +8,31 @@ const locationSchema = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Factory for the `get_location` tool, bound to the current request/user.
|
||||
* @param {{ userId?: string, req?: import('express').Request }} params
|
||||
* @returns {Promise<import('@librechat/agents/langchain/tools').DynamicStructuredTool>}
|
||||
* GetLocation - returns the user's shared location (place, coordinates, timezone).
|
||||
* Reads the resolved app config (admin feature flag) and the user's stored
|
||||
* `personalization.location` from the request, and delegates formatting to
|
||||
* `formatLocationToolResult`. Gracefully reports when disabled or not shared.
|
||||
*/
|
||||
module.exports = async function createLocationTool({ req } = {}) {
|
||||
return tool(
|
||||
async () => {
|
||||
const featureEnabled = req?.config?.location?.enabled !== false;
|
||||
const location = req?.user?.personalization?.location;
|
||||
return formatLocationToolResult(location, { featureEnabled });
|
||||
},
|
||||
{
|
||||
name: 'get_location',
|
||||
description:
|
||||
"Returns the user's current location (place, coordinates, timezone) when they have shared it. Use it to tailor language, regional context, units, or weather lookups.",
|
||||
schema: locationSchema,
|
||||
},
|
||||
);
|
||||
};
|
||||
class GetLocation extends Tool {
|
||||
constructor(fields = {}) {
|
||||
super();
|
||||
|
||||
/** @type {boolean} Used to initialize the Tool without request context. */
|
||||
this.override = fields.override ?? false;
|
||||
this.req = fields.req;
|
||||
this.userId = fields.userId;
|
||||
|
||||
this.name = 'get_location';
|
||||
this.description =
|
||||
"Returns the user's current location (place, coordinates, timezone) when they have shared it. Use it to tailor language, regional context, units, or weather lookups.";
|
||||
this.schema = locationSchema;
|
||||
}
|
||||
|
||||
async _call() {
|
||||
const featureEnabled = this.req?.config?.location?.enabled !== false;
|
||||
const location = this.req?.user?.personalization?.location;
|
||||
return formatLocationToolResult(location, { featureEnabled });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GetLocation;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
const createLocationTool = require('./GetLocation');
|
||||
const GetLocation = require('./GetLocation');
|
||||
|
||||
const makeReq = ({ location, featureEnabled = true } = {}) => ({
|
||||
config: { location: { enabled: featureEnabled } },
|
||||
user: { id: 'user-1', personalization: location ? { location } : {} },
|
||||
});
|
||||
|
||||
describe('createLocationTool', () => {
|
||||
describe('GetLocation tool', () => {
|
||||
it('returns the user location when enabled', async () => {
|
||||
const tool = await createLocationTool({
|
||||
userId: 'user-1',
|
||||
const tool = new GetLocation({
|
||||
req: makeReq({
|
||||
location: {
|
||||
enabled: true,
|
||||
|
|
@ -24,17 +23,22 @@ describe('createLocationTool', () => {
|
|||
});
|
||||
|
||||
it('returns a not-shared message when the user has not opted in', async () => {
|
||||
const tool = await createLocationTool({ userId: 'user-1', req: makeReq({}) });
|
||||
const tool = new GetLocation({ req: makeReq({}) });
|
||||
const result = await tool.invoke({});
|
||||
expect(result).toMatch(/has not shared/i);
|
||||
});
|
||||
|
||||
it('returns a disabled message when the admin flag is off', async () => {
|
||||
const tool = await createLocationTool({
|
||||
userId: 'user-1',
|
||||
const tool = new GetLocation({
|
||||
req: makeReq({ location: { enabled: true, manual: 'X' }, featureEnabled: false }),
|
||||
});
|
||||
const result = await tool.invoke({});
|
||||
expect(result).toMatch(/disabled/i);
|
||||
});
|
||||
|
||||
it('can be constructed without request context (override) for tool discovery', () => {
|
||||
const tool = new GetLocation({ override: true });
|
||||
expect(tool.name).toBe('get_location');
|
||||
expect(tool.schema).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
const { Tool } = require('@librechat/agents/langchain/tools');
|
||||
const GetLocation = require('../GetLocation');
|
||||
|
||||
describe('get_location tool registration', () => {
|
||||
it('GetLocation is a Tool subclass so loadAndFormatTools discovers it', () => {
|
||||
expect(GetLocation.prototype instanceof Tool).toBe(true);
|
||||
});
|
||||
|
||||
it('can be instantiated with override for discovery without request context', () => {
|
||||
const tool = new GetLocation({ override: true });
|
||||
expect(tool.name).toBe('get_location');
|
||||
expect(tool.schema).toBeDefined();
|
||||
});
|
||||
|
||||
it('has a plain-object schema compatible with loadAndFormatTools (non-Zod)', () => {
|
||||
const tool = new GetLocation({ override: true });
|
||||
expect(tool.schema).toEqual({ type: 'object', properties: {}, required: [] });
|
||||
});
|
||||
});
|
||||
|
|
@ -34,7 +34,7 @@ const {
|
|||
TavilySearchResults,
|
||||
createGeminiImageTool,
|
||||
createOpenAIImageTools,
|
||||
createLocationTool,
|
||||
GetLocation,
|
||||
} = require('../');
|
||||
const {
|
||||
createMCPTool,
|
||||
|
|
@ -182,6 +182,7 @@ const loadTools = async ({
|
|||
google: GoogleSearchAPI,
|
||||
open_weather: OpenWeather,
|
||||
wolfram: StructuredWolfram,
|
||||
get_location: GetLocation,
|
||||
'stable-diffusion': StructuredSD,
|
||||
'azure-ai-search': StructuredACS,
|
||||
traversaal_search: TraversaalSearch,
|
||||
|
|
@ -231,9 +232,6 @@ const loadTools = async ({
|
|||
fileStrategy,
|
||||
});
|
||||
},
|
||||
get_location: async () => {
|
||||
return createLocationTool({ userId: user, req: options.req });
|
||||
},
|
||||
};
|
||||
|
||||
const requestedTools = {};
|
||||
|
|
@ -264,6 +262,7 @@ const loadTools = async ({
|
|||
dalle: imageGenOptions,
|
||||
'stable-diffusion': imageGenOptions,
|
||||
gemini_image_gen: imageGenOptions,
|
||||
get_location: { req: options.req },
|
||||
};
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
|
|
|
|||
|
|
@ -73,7 +73,12 @@ export default function Personalization({
|
|||
|
||||
const handleLocationToggle = (checked: boolean) => {
|
||||
setLocationEnabled(checked);
|
||||
persistLocation({ enabled: checked, source: 'manual', manual: manualLocation || undefined });
|
||||
const existing = user?.personalization?.location;
|
||||
persistLocation({
|
||||
...(existing ?? {}),
|
||||
enabled: checked,
|
||||
manual: manualLocation || existing?.manual,
|
||||
});
|
||||
};
|
||||
|
||||
const handleManualBlur = () => {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,16 @@ import * as authQueries from '~/data-provider/Auth/queries';
|
|||
import Personalization from '../Personalization';
|
||||
|
||||
const mockMutate = jest.fn();
|
||||
const mockShowToast = jest.fn();
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
...jest.requireActual('@librechat/client'),
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear();
|
||||
mockShowToast.mockClear();
|
||||
|
||||
jest
|
||||
.spyOn(authQueries, 'useGetUserQuery')
|
||||
|
|
@ -63,4 +70,28 @@ describe('Personalization location section', () => {
|
|||
expect.objectContaining({ manual: 'Tokyo, Japan', source: 'manual' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a warning toast when geolocation is denied and does not call mutate with auto source', async () => {
|
||||
const deniedError = Object.assign(new Error('denied'), {
|
||||
code: 1,
|
||||
PERMISSION_DENIED: 1,
|
||||
});
|
||||
jest
|
||||
.spyOn(global.navigator.geolocation, 'getCurrentPosition')
|
||||
.mockImplementation((_success, error) => error?.(deniedError as GeolocationPositionError));
|
||||
|
||||
render(
|
||||
<Personalization hasMemoryOptOut={false} hasLocationSharing hasAnyPersonalizationFeature />,
|
||||
);
|
||||
|
||||
const button = screen.getByText('Use my device location');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(mockShowToast).toHaveBeenCalled());
|
||||
expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ status: 'warning' }));
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalledWith(expect.objectContaining({ source: 'auto' }));
|
||||
|
||||
expect(screen.getByLabelText('Set location manually')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ const DEFAULT_ENDPOINT = 'https://api.bigdatacloud.net/data/reverse-geocode-clie
|
|||
|
||||
const round = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
interface BigDataCloudResponse {
|
||||
city?: string;
|
||||
locality?: string;
|
||||
principalSubdivision?: string;
|
||||
countryName?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedLocation {
|
||||
place?: string;
|
||||
coordinates: { latitude: number; longitude: number };
|
||||
|
|
@ -31,9 +38,9 @@ export async function reverseGeocode(
|
|||
if (!response.ok) {
|
||||
return { coordinates, timezone };
|
||||
}
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as BigDataCloudResponse;
|
||||
const place = [data.city || data.locality, data.principalSubdivision, data.countryName]
|
||||
.filter((part: unknown): part is string => typeof part === 'string' && part.length > 0)
|
||||
.filter((part): part is string => typeof part === 'string' && part.length > 0)
|
||||
.join(', ');
|
||||
return { place: place || undefined, coordinates, timezone };
|
||||
} catch {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue