mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 08:12:00 +02:00

* ✨ feat: Add OpenWeather Tool for Weather Data Retrieval 🌤️ * chore: linting * chore: move test files * fix: tool icon, allow user-provided keys, conform to app key assignment pattern * chore: linting not included in #5212 --------- Co-authored-by: Jonathan Addington <jonathan.addington@jmaddington.com>
317 lines
10 KiB
JavaScript
317 lines
10 KiB
JavaScript
const { Tool } = require('@langchain/core/tools');
|
|
const { z } = require('zod');
|
|
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
|
|
const fetch = require('node-fetch');
|
|
|
|
/**
|
|
* Map user-friendly units to OpenWeather units.
|
|
* Defaults to Celsius if not specified.
|
|
*/
|
|
function mapUnitsToOpenWeather(unit) {
|
|
if (!unit) {
|
|
return 'metric';
|
|
} // Default to Celsius
|
|
switch (unit) {
|
|
case 'Celsius':
|
|
return 'metric';
|
|
case 'Kelvin':
|
|
return 'standard';
|
|
case 'Fahrenheit':
|
|
return 'imperial';
|
|
default:
|
|
return 'metric'; // fallback
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively round temperature fields in the API response.
|
|
*/
|
|
function roundTemperatures(obj) {
|
|
const tempKeys = new Set([
|
|
'temp',
|
|
'feels_like',
|
|
'dew_point',
|
|
'day',
|
|
'min',
|
|
'max',
|
|
'night',
|
|
'eve',
|
|
'morn',
|
|
'afternoon',
|
|
'morning',
|
|
'evening',
|
|
]);
|
|
|
|
if (Array.isArray(obj)) {
|
|
return obj.map((item) => roundTemperatures(item));
|
|
} else if (obj && typeof obj === 'object') {
|
|
for (const key of Object.keys(obj)) {
|
|
const value = obj[key];
|
|
if (value && typeof value === 'object') {
|
|
obj[key] = roundTemperatures(value);
|
|
} else if (typeof value === 'number' && tempKeys.has(key)) {
|
|
obj[key] = Math.round(value);
|
|
}
|
|
}
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
class OpenWeather extends Tool {
|
|
name = 'open_weather';
|
|
description =
|
|
'Provides weather data from OpenWeather One Call API 3.0. ' +
|
|
'Actions: help, current_forecast, timestamp, daily_aggregation, overview. ' +
|
|
'If lat/lon not provided, specify "city" for geocoding. ' +
|
|
'Units: "Celsius", "Kelvin", or "Fahrenheit" (default: Celsius). ' +
|
|
'For timestamp action, use "date" in YYYY-MM-DD format.';
|
|
|
|
schema = z.object({
|
|
action: z.enum(['help', 'current_forecast', 'timestamp', 'daily_aggregation', 'overview']),
|
|
city: z.string().optional(),
|
|
lat: z.number().optional(),
|
|
lon: z.number().optional(),
|
|
exclude: z.string().optional(),
|
|
units: z.enum(['Celsius', 'Kelvin', 'Fahrenheit']).optional(),
|
|
lang: z.string().optional(),
|
|
date: z.string().optional(), // For timestamp and daily_aggregation
|
|
tz: z.string().optional(),
|
|
});
|
|
|
|
constructor(fields = {}) {
|
|
super();
|
|
this.envVar = 'OPENWEATHER_API_KEY';
|
|
this.override = fields.override ?? false;
|
|
this.apiKey = fields[this.envVar] ?? this.getApiKey();
|
|
}
|
|
|
|
getApiKey() {
|
|
const key = getEnvironmentVariable(this.envVar);
|
|
if (!key && !this.override) {
|
|
throw new Error(`Missing ${this.envVar} environment variable.`);
|
|
}
|
|
return key;
|
|
}
|
|
|
|
async geocodeCity(city) {
|
|
const geocodeUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent(
|
|
city,
|
|
)}&limit=1&appid=${this.apiKey}`;
|
|
const res = await fetch(geocodeUrl);
|
|
const data = await res.json();
|
|
if (!res.ok || !Array.isArray(data) || data.length === 0) {
|
|
throw new Error(`Could not find coordinates for city: ${city}`);
|
|
}
|
|
return { lat: data[0].lat, lon: data[0].lon };
|
|
}
|
|
|
|
convertDateToUnix(dateStr) {
|
|
const parts = dateStr.split('-');
|
|
if (parts.length !== 3) {
|
|
throw new Error('Invalid date format. Expected YYYY-MM-DD.');
|
|
}
|
|
const year = parseInt(parts[0], 10);
|
|
const month = parseInt(parts[1], 10);
|
|
const day = parseInt(parts[2], 10);
|
|
if (isNaN(year) || isNaN(month) || isNaN(day)) {
|
|
throw new Error('Invalid date format. Expected YYYY-MM-DD with valid numbers.');
|
|
}
|
|
|
|
const dateObj = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
|
|
if (isNaN(dateObj.getTime())) {
|
|
throw new Error('Invalid date provided. Cannot parse into a valid date.');
|
|
}
|
|
|
|
return Math.floor(dateObj.getTime() / 1000);
|
|
}
|
|
|
|
async _call(args) {
|
|
try {
|
|
const { action, city, lat, lon, exclude, units, lang, date, tz } = args;
|
|
const owmUnits = mapUnitsToOpenWeather(units);
|
|
|
|
if (action === 'help') {
|
|
return JSON.stringify(
|
|
{
|
|
title: 'OpenWeather One Call API 3.0 Help',
|
|
description: 'Guidance on using the OpenWeather One Call API 3.0.',
|
|
endpoints: {
|
|
current_and_forecast: {
|
|
endpoint: 'data/3.0/onecall',
|
|
data_provided: [
|
|
'Current weather',
|
|
'Minute forecast (1h)',
|
|
'Hourly forecast (48h)',
|
|
'Daily forecast (8 days)',
|
|
'Government weather alerts',
|
|
],
|
|
required_params: [['lat', 'lon'], ['city']],
|
|
optional_params: ['exclude', 'units (Celsius/Kelvin/Fahrenheit)', 'lang'],
|
|
usage_example: {
|
|
city: 'Knoxville, Tennessee',
|
|
units: 'Fahrenheit',
|
|
lang: 'en',
|
|
},
|
|
},
|
|
weather_for_timestamp: {
|
|
endpoint: 'data/3.0/onecall/timemachine',
|
|
data_provided: [
|
|
'Historical weather (since 1979-01-01)',
|
|
'Future forecast up to 4 days ahead',
|
|
],
|
|
required_params: [
|
|
['lat', 'lon', 'date (YYYY-MM-DD)'],
|
|
['city', 'date (YYYY-MM-DD)'],
|
|
],
|
|
optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang'],
|
|
usage_example: {
|
|
city: 'Knoxville, Tennessee',
|
|
date: '2020-03-04',
|
|
units: 'Fahrenheit',
|
|
lang: 'en',
|
|
},
|
|
},
|
|
daily_aggregation: {
|
|
endpoint: 'data/3.0/onecall/day_summary',
|
|
data_provided: [
|
|
'Aggregated weather data for a specific date (1979-01-02 to 1.5 years ahead)',
|
|
],
|
|
required_params: [
|
|
['lat', 'lon', 'date (YYYY-MM-DD)'],
|
|
['city', 'date (YYYY-MM-DD)'],
|
|
],
|
|
optional_params: ['units (Celsius/Kelvin/Fahrenheit)', 'lang', 'tz'],
|
|
usage_example: {
|
|
city: 'Knoxville, Tennessee',
|
|
date: '2020-03-04',
|
|
units: 'Celsius',
|
|
lang: 'en',
|
|
},
|
|
},
|
|
weather_overview: {
|
|
endpoint: 'data/3.0/onecall/overview',
|
|
data_provided: ['Human-readable weather summary (today/tomorrow)'],
|
|
required_params: [['lat', 'lon'], ['city']],
|
|
optional_params: ['date (YYYY-MM-DD)', 'units (Celsius/Kelvin/Fahrenheit)'],
|
|
usage_example: {
|
|
city: 'Knoxville, Tennessee',
|
|
date: '2024-05-13',
|
|
units: 'Celsius',
|
|
},
|
|
},
|
|
},
|
|
notes: [
|
|
'If lat/lon not provided, you can specify a city name and it will be geocoded.',
|
|
'For the timestamp action, provide a date in YYYY-MM-DD format instead of a Unix timestamp.',
|
|
'By default, temperatures are returned in Celsius.',
|
|
'You can specify units as Celsius, Kelvin, or Fahrenheit.',
|
|
'All temperatures are rounded to the nearest degree.',
|
|
],
|
|
errors: [
|
|
'400: Bad Request (missing/invalid params)',
|
|
'401: Unauthorized (check API key)',
|
|
'404: Not Found (no data or city)',
|
|
'429: Too many requests',
|
|
'5xx: Internal error',
|
|
],
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
let finalLat = lat;
|
|
let finalLon = lon;
|
|
|
|
// If lat/lon not provided but city is given, geocode it
|
|
if ((finalLat == null || finalLon == null) && city) {
|
|
const coords = await this.geocodeCity(city);
|
|
finalLat = coords.lat;
|
|
finalLon = coords.lon;
|
|
}
|
|
|
|
if (['current_forecast', 'timestamp', 'daily_aggregation', 'overview'].includes(action)) {
|
|
if (typeof finalLat !== 'number' || typeof finalLon !== 'number') {
|
|
return 'Error: lat and lon are required and must be numbers for this action (or specify \'city\').';
|
|
}
|
|
}
|
|
|
|
const baseUrl = 'https://api.openweathermap.org/data/3.0';
|
|
let endpoint = '';
|
|
const params = new URLSearchParams({ appid: this.apiKey, units: owmUnits });
|
|
|
|
let dt;
|
|
if (action === 'timestamp') {
|
|
if (!date) {
|
|
return 'Error: For timestamp action, a \'date\' in YYYY-MM-DD format is required.';
|
|
}
|
|
dt = this.convertDateToUnix(date);
|
|
}
|
|
|
|
if (action === 'daily_aggregation' && !date) {
|
|
return 'Error: date (YYYY-MM-DD) is required for daily_aggregation action.';
|
|
}
|
|
|
|
switch (action) {
|
|
case 'current_forecast':
|
|
endpoint = '/onecall';
|
|
params.append('lat', String(finalLat));
|
|
params.append('lon', String(finalLon));
|
|
if (exclude) {
|
|
params.append('exclude', exclude);
|
|
}
|
|
if (lang) {
|
|
params.append('lang', lang);
|
|
}
|
|
break;
|
|
case 'timestamp':
|
|
endpoint = '/onecall/timemachine';
|
|
params.append('lat', String(finalLat));
|
|
params.append('lon', String(finalLon));
|
|
params.append('dt', String(dt));
|
|
if (lang) {
|
|
params.append('lang', lang);
|
|
}
|
|
break;
|
|
case 'daily_aggregation':
|
|
endpoint = '/onecall/day_summary';
|
|
params.append('lat', String(finalLat));
|
|
params.append('lon', String(finalLon));
|
|
params.append('date', date);
|
|
if (lang) {
|
|
params.append('lang', lang);
|
|
}
|
|
if (tz) {
|
|
params.append('tz', tz);
|
|
}
|
|
break;
|
|
case 'overview':
|
|
endpoint = '/onecall/overview';
|
|
params.append('lat', String(finalLat));
|
|
params.append('lon', String(finalLon));
|
|
if (date) {
|
|
params.append('date', date);
|
|
}
|
|
break;
|
|
default:
|
|
return `Error: Unknown action: ${action}`;
|
|
}
|
|
|
|
const url = `${baseUrl}${endpoint}?${params.toString()}`;
|
|
const response = await fetch(url);
|
|
const json = await response.json();
|
|
if (!response.ok) {
|
|
return `Error: OpenWeather API request failed with status ${response.status}: ${
|
|
json.message || JSON.stringify(json)
|
|
}`;
|
|
}
|
|
|
|
const roundedJson = roundTemperatures(json);
|
|
return JSON.stringify(roundedJson);
|
|
} catch (err) {
|
|
return `Error: ${err.message}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = OpenWeather;
|