mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
🌤️ feat: Add OpenWeather Tool for Weather Data Retrieval (#5246)
* ✨ 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>
This commit is contained in:
parent
ea1a5c8a30
commit
0855677a36
9 changed files with 927 additions and 6 deletions
|
|
@ -256,6 +256,7 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
|
||||||
# DALLE3_AZURE_API_VERSION=
|
# DALLE3_AZURE_API_VERSION=
|
||||||
# DALLE2_AZURE_API_VERSION=
|
# DALLE2_AZURE_API_VERSION=
|
||||||
|
|
||||||
|
|
||||||
# Google
|
# Google
|
||||||
#-----------------
|
#-----------------
|
||||||
GOOGLE_SEARCH_API_KEY=
|
GOOGLE_SEARCH_API_KEY=
|
||||||
|
|
@ -514,4 +515,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||||
|
|
||||||
# no-cache: Forces validation with server before using cached version
|
# no-cache: Forces validation with server before using cached version
|
||||||
# no-store: Prevents storing the response entirely
|
# no-store: Prevents storing the response entirely
|
||||||
# must-revalidate: Prevents using stale content when offline
|
# must-revalidate: Prevents using stale content when offline
|
||||||
|
|
||||||
|
#=====================================================#
|
||||||
|
# OpenWeather #
|
||||||
|
#=====================================================#
|
||||||
|
OPENWEATHER_API_KEY=
|
||||||
|
|
@ -8,6 +8,7 @@ const StructuredSD = require('./structured/StableDiffusion');
|
||||||
const GoogleSearchAPI = require('./structured/GoogleSearch');
|
const GoogleSearchAPI = require('./structured/GoogleSearch');
|
||||||
const TraversaalSearch = require('./structured/TraversaalSearch');
|
const TraversaalSearch = require('./structured/TraversaalSearch');
|
||||||
const TavilySearchResults = require('./structured/TavilySearchResults');
|
const TavilySearchResults = require('./structured/TavilySearchResults');
|
||||||
|
const OpenWeather = require('./structured/OpenWeather');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
availableTools,
|
availableTools,
|
||||||
|
|
@ -19,4 +20,5 @@ module.exports = {
|
||||||
TraversaalSearch,
|
TraversaalSearch,
|
||||||
StructuredWolfram,
|
StructuredWolfram,
|
||||||
TavilySearchResults,
|
TavilySearchResults,
|
||||||
|
OpenWeather,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,6 @@
|
||||||
"pluginKey": "calculator",
|
"pluginKey": "calculator",
|
||||||
"description": "Perform simple and complex mathematical calculations.",
|
"description": "Perform simple and complex mathematical calculations.",
|
||||||
"icon": "https://i.imgur.com/RHsSG5h.png",
|
"icon": "https://i.imgur.com/RHsSG5h.png",
|
||||||
"isAuthRequired": "false",
|
|
||||||
"authConfig": []
|
"authConfig": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -135,7 +134,20 @@
|
||||||
{
|
{
|
||||||
"authField": "AZURE_AI_SEARCH_API_KEY",
|
"authField": "AZURE_AI_SEARCH_API_KEY",
|
||||||
"label": "Azure AI Search API Key",
|
"label": "Azure AI Search API Key",
|
||||||
"description": "You need to provideq your API Key for Azure AI Search."
|
"description": "You need to provide your API Key for Azure AI Search."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OpenWeather",
|
||||||
|
"pluginKey": "open_weather",
|
||||||
|
"description": "Get weather forecasts and historical data from the OpenWeather API",
|
||||||
|
"icon": "/assets/openweather.png",
|
||||||
|
"authConfig": [
|
||||||
|
{
|
||||||
|
"authField": "OPENWEATHER_API_KEY",
|
||||||
|
"label": "OpenWeather API Key",
|
||||||
|
"description": "Sign up at <a href=\"https://home.openweathermap.org/users/sign_up\" target=\"_blank\">OpenWeather</a>, then get your key at <a href=\"https://home.openweathermap.org/api_keys\" target=\"_blank\">API keys</a>."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
317
api/app/clients/tools/structured/OpenWeather.js
Normal file
317
api/app/clients/tools/structured/OpenWeather.js
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
// __tests__/openWeather.integration.test.js
|
||||||
|
const OpenWeather = require('../OpenWeather');
|
||||||
|
|
||||||
|
describe('OpenWeather Tool (Integration Test)', () => {
|
||||||
|
let tool;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
tool = new OpenWeather({ override: true });
|
||||||
|
console.log('API Key present:', !!process.env.OPENWEATHER_API_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current_forecast with a real API key returns current weather', async () => {
|
||||||
|
// Check if API key is available
|
||||||
|
if (!process.env.OPENWEATHER_API_KEY) {
|
||||||
|
console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'London',
|
||||||
|
units: 'Celsius',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Raw API response:', result);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed).toHaveProperty('current');
|
||||||
|
expect(typeof parsed.current.temp).toBe('number');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test failed with error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timestamp action with real API key returns historical data', async () => {
|
||||||
|
if (!process.env.OPENWEATHER_API_KEY) {
|
||||||
|
console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use a date from yesterday to ensure data availability
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const dateStr = yesterday.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'timestamp',
|
||||||
|
city: 'London',
|
||||||
|
date: dateStr,
|
||||||
|
units: 'Celsius',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Timestamp API response:', result);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed).toHaveProperty('data');
|
||||||
|
expect(Array.isArray(parsed.data)).toBe(true);
|
||||||
|
expect(parsed.data[0]).toHaveProperty('temp');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Timestamp test failed with error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily_aggregation action with real API key returns aggregated data', async () => {
|
||||||
|
if (!process.env.OPENWEATHER_API_KEY) {
|
||||||
|
console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use yesterday's date for aggregation
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const dateStr = yesterday.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'daily_aggregation',
|
||||||
|
city: 'London',
|
||||||
|
date: dateStr,
|
||||||
|
units: 'Celsius',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Daily aggregation API response:', result);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed).toHaveProperty('temperature');
|
||||||
|
expect(parsed.temperature).toHaveProperty('morning');
|
||||||
|
expect(parsed.temperature).toHaveProperty('afternoon');
|
||||||
|
expect(parsed.temperature).toHaveProperty('evening');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Daily aggregation test failed with error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overview action with real API key returns weather summary', async () => {
|
||||||
|
if (!process.env.OPENWEATHER_API_KEY) {
|
||||||
|
console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'overview',
|
||||||
|
city: 'London',
|
||||||
|
units: 'Celsius',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Overview API response:', result);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed).toHaveProperty('weather_overview');
|
||||||
|
expect(typeof parsed.weather_overview).toBe('string');
|
||||||
|
expect(parsed.weather_overview.length).toBeGreaterThan(0);
|
||||||
|
expect(parsed).toHaveProperty('date');
|
||||||
|
expect(parsed).toHaveProperty('units');
|
||||||
|
expect(parsed.units).toBe('metric');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Overview test failed with error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different temperature units return correct values', async () => {
|
||||||
|
if (!process.env.OPENWEATHER_API_KEY) {
|
||||||
|
console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test Celsius
|
||||||
|
let result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'London',
|
||||||
|
units: 'Celsius',
|
||||||
|
});
|
||||||
|
let parsed = JSON.parse(result);
|
||||||
|
const celsiusTemp = parsed.current.temp;
|
||||||
|
|
||||||
|
// Test Kelvin
|
||||||
|
result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'London',
|
||||||
|
units: 'Kelvin',
|
||||||
|
});
|
||||||
|
parsed = JSON.parse(result);
|
||||||
|
const kelvinTemp = parsed.current.temp;
|
||||||
|
|
||||||
|
// Test Fahrenheit
|
||||||
|
result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'London',
|
||||||
|
units: 'Fahrenheit',
|
||||||
|
});
|
||||||
|
parsed = JSON.parse(result);
|
||||||
|
const fahrenheitTemp = parsed.current.temp;
|
||||||
|
|
||||||
|
// Verify temperature conversions are roughly correct
|
||||||
|
// K = C + 273.15
|
||||||
|
// F = (C * 9/5) + 32
|
||||||
|
const celsiusToKelvin = Math.round(celsiusTemp + 273.15);
|
||||||
|
const celsiusToFahrenheit = Math.round((celsiusTemp * 9) / 5 + 32);
|
||||||
|
|
||||||
|
console.log('Temperature comparisons:', {
|
||||||
|
celsius: celsiusTemp,
|
||||||
|
kelvin: kelvinTemp,
|
||||||
|
fahrenheit: fahrenheitTemp,
|
||||||
|
calculatedKelvin: celsiusToKelvin,
|
||||||
|
calculatedFahrenheit: celsiusToFahrenheit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow for some rounding differences
|
||||||
|
expect(Math.abs(kelvinTemp - celsiusToKelvin)).toBeLessThanOrEqual(1);
|
||||||
|
expect(Math.abs(fahrenheitTemp - celsiusToFahrenheit)).toBeLessThanOrEqual(1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Temperature units test failed with error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('language parameter returns localized data', async () => {
|
||||||
|
if (!process.env.OPENWEATHER_API_KEY) {
|
||||||
|
console.warn('Skipping real API test, no OPENWEATHER_API_KEY found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test with English
|
||||||
|
let result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'Paris',
|
||||||
|
units: 'Celsius',
|
||||||
|
lang: 'en',
|
||||||
|
});
|
||||||
|
let parsed = JSON.parse(result);
|
||||||
|
const englishDescription = parsed.current.weather[0].description;
|
||||||
|
|
||||||
|
// Test with French
|
||||||
|
result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'Paris',
|
||||||
|
units: 'Celsius',
|
||||||
|
lang: 'fr',
|
||||||
|
});
|
||||||
|
parsed = JSON.parse(result);
|
||||||
|
const frenchDescription = parsed.current.weather[0].description;
|
||||||
|
|
||||||
|
console.log('Language comparison:', {
|
||||||
|
english: englishDescription,
|
||||||
|
french: frenchDescription,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify descriptions are different (indicating translation worked)
|
||||||
|
expect(englishDescription).not.toBe(frenchDescription);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Language test failed with error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
358
api/app/clients/tools/structured/specs/openweather.test.js
Normal file
358
api/app/clients/tools/structured/specs/openweather.test.js
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
// __tests__/openweather.test.js
|
||||||
|
const OpenWeather = require('../OpenWeather');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
// Mock environment variable
|
||||||
|
process.env.OPENWEATHER_API_KEY = 'test-api-key';
|
||||||
|
|
||||||
|
// Mock the fetch function globally
|
||||||
|
jest.mock('node-fetch', () => jest.fn());
|
||||||
|
|
||||||
|
describe('OpenWeather Tool', () => {
|
||||||
|
let tool;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
tool = new OpenWeather();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('action=help returns help instructions', async () => {
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'help',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(typeof result).toBe('string');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.title).toBe('OpenWeather One Call API 3.0 Help');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current_forecast with a city and successful geocoding + forecast', async () => {
|
||||||
|
// Mock geocoding response
|
||||||
|
fetch.mockImplementationOnce((url) => {
|
||||||
|
if (url.includes('geo/1.0/direct')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [{ lat: 35.9606, lon: -83.9207 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject('Unexpected fetch call for geocoding');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock forecast response
|
||||||
|
fetch.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
current: { temp: 293.15, feels_like: 295.15 },
|
||||||
|
daily: [{ temp: { day: 293.15, night: 283.15 } }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'Knoxville, Tennessee',
|
||||||
|
units: 'Kelvin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.current.temp).toBe(293);
|
||||||
|
expect(parsed.current.feels_like).toBe(295);
|
||||||
|
expect(parsed.daily[0].temp.day).toBe(293);
|
||||||
|
expect(parsed.daily[0].temp.night).toBe(283);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timestamp action with valid date returns mocked historical data', async () => {
|
||||||
|
// Mock geocoding response
|
||||||
|
fetch.mockImplementationOnce((url) => {
|
||||||
|
if (url.includes('geo/1.0/direct')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [{ lat: 35.9606, lon: -83.9207 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject('Unexpected fetch call for geocoding');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock historical weather response
|
||||||
|
fetch.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
dt: 1583280000,
|
||||||
|
temp: 283.15,
|
||||||
|
feels_like: 280.15,
|
||||||
|
humidity: 75,
|
||||||
|
weather: [{ description: 'clear sky' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'timestamp',
|
||||||
|
city: 'Knoxville, Tennessee',
|
||||||
|
date: '2020-03-04',
|
||||||
|
units: 'Kelvin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.data[0].temp).toBe(283);
|
||||||
|
expect(parsed.data[0].feels_like).toBe(280);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily_aggregation action returns aggregated weather data', async () => {
|
||||||
|
// Mock geocoding response
|
||||||
|
fetch.mockImplementationOnce((url) => {
|
||||||
|
if (url.includes('geo/1.0/direct')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [{ lat: 35.9606, lon: -83.9207 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject('Unexpected fetch call for geocoding');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock daily aggregation response
|
||||||
|
fetch.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
date: '2020-03-04',
|
||||||
|
temperature: {
|
||||||
|
morning: 283.15,
|
||||||
|
afternoon: 293.15,
|
||||||
|
evening: 288.15,
|
||||||
|
},
|
||||||
|
humidity: {
|
||||||
|
morning: 75,
|
||||||
|
afternoon: 60,
|
||||||
|
evening: 70,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'daily_aggregation',
|
||||||
|
city: 'Knoxville, Tennessee',
|
||||||
|
date: '2020-03-04',
|
||||||
|
units: 'Kelvin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.temperature.morning).toBe(283);
|
||||||
|
expect(parsed.temperature.afternoon).toBe(293);
|
||||||
|
expect(parsed.temperature.evening).toBe(288);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overview action returns weather summary', async () => {
|
||||||
|
// Mock geocoding response
|
||||||
|
fetch.mockImplementationOnce((url) => {
|
||||||
|
if (url.includes('geo/1.0/direct')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [{ lat: 35.9606, lon: -83.9207 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject('Unexpected fetch call for geocoding');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock overview response
|
||||||
|
fetch.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
date: '2024-01-07',
|
||||||
|
lat: 35.9606,
|
||||||
|
lon: -83.9207,
|
||||||
|
tz: '+00:00',
|
||||||
|
units: 'metric',
|
||||||
|
weather_overview:
|
||||||
|
'Currently, the temperature is 2°C with a real feel of -2°C. The sky is clear with moderate wind.',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'overview',
|
||||||
|
city: 'Knoxville, Tennessee',
|
||||||
|
units: 'Celsius',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed).toHaveProperty('weather_overview');
|
||||||
|
expect(typeof parsed.weather_overview).toBe('string');
|
||||||
|
expect(parsed.weather_overview.length).toBeGreaterThan(0);
|
||||||
|
expect(parsed).toHaveProperty('date');
|
||||||
|
expect(parsed).toHaveProperty('units');
|
||||||
|
expect(parsed.units).toBe('metric');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('temperature units are correctly converted', async () => {
|
||||||
|
// Mock geocoding response for all three calls
|
||||||
|
const geocodingMock = Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [{ lat: 35.9606, lon: -83.9207 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock weather response for Kelvin
|
||||||
|
const kelvinMock = Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
current: { temp: 293.15 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock weather response for Celsius
|
||||||
|
const celsiusMock = Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
current: { temp: 20 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock weather response for Fahrenheit
|
||||||
|
const fahrenheitMock = Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
current: { temp: 68 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test Kelvin
|
||||||
|
fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => kelvinMock);
|
||||||
|
|
||||||
|
let result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'Knoxville, Tennessee',
|
||||||
|
units: 'Kelvin',
|
||||||
|
});
|
||||||
|
let parsed = JSON.parse(result);
|
||||||
|
expect(parsed.current.temp).toBe(293);
|
||||||
|
|
||||||
|
// Test Celsius
|
||||||
|
fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => celsiusMock);
|
||||||
|
|
||||||
|
result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'Knoxville, Tennessee',
|
||||||
|
units: 'Celsius',
|
||||||
|
});
|
||||||
|
parsed = JSON.parse(result);
|
||||||
|
expect(parsed.current.temp).toBe(20);
|
||||||
|
|
||||||
|
// Test Fahrenheit
|
||||||
|
fetch.mockImplementationOnce(() => geocodingMock).mockImplementationOnce(() => fahrenheitMock);
|
||||||
|
|
||||||
|
result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'Knoxville, Tennessee',
|
||||||
|
units: 'Fahrenheit',
|
||||||
|
});
|
||||||
|
parsed = JSON.parse(result);
|
||||||
|
expect(parsed.current.temp).toBe(68);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timestamp action without a date returns an error message', async () => {
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'timestamp',
|
||||||
|
lat: 35.9606,
|
||||||
|
lon: -83.9207,
|
||||||
|
});
|
||||||
|
expect(result).toMatch(
|
||||||
|
/Error: For timestamp action, a 'date' in YYYY-MM-DD format is required./,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily_aggregation action without a date returns an error message', async () => {
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'daily_aggregation',
|
||||||
|
lat: 35.9606,
|
||||||
|
lon: -83.9207,
|
||||||
|
});
|
||||||
|
expect(result).toMatch(/Error: date \(YYYY-MM-DD\) is required for daily_aggregation action./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown action returns an error due to schema validation', async () => {
|
||||||
|
await expect(
|
||||||
|
tool.call({
|
||||||
|
action: 'unknown_action',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Received tool input did not match expected schema/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('geocoding failure returns a descriptive error', async () => {
|
||||||
|
fetch.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'NowhereCity',
|
||||||
|
});
|
||||||
|
expect(result).toMatch(/Error: Could not find coordinates for city: NowhereCity/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('API request failure returns an error', async () => {
|
||||||
|
// Mock geocoding success
|
||||||
|
fetch.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [{ lat: 35.9606, lon: -83.9207 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock weather request failure
|
||||||
|
fetch.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ message: 'Not found' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'current_forecast',
|
||||||
|
city: 'Knoxville, Tennessee',
|
||||||
|
});
|
||||||
|
expect(result).toMatch(/Error: OpenWeather API request failed with status 404: Not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid date format returns an error', async () => {
|
||||||
|
// Mock geocoding response first
|
||||||
|
fetch.mockImplementationOnce((url) => {
|
||||||
|
if (url.includes('geo/1.0/direct')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [{ lat: 35.9606, lon: -83.9207 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject('Unexpected fetch call for geocoding');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock timestamp API response
|
||||||
|
fetch.mockImplementationOnce((url) => {
|
||||||
|
if (url.includes('onecall/timemachine')) {
|
||||||
|
throw new Error('Invalid date format. Expected YYYY-MM-DD.');
|
||||||
|
}
|
||||||
|
return Promise.reject('Unexpected fetch call');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tool.call({
|
||||||
|
action: 'timestamp',
|
||||||
|
city: 'Knoxville, Tennessee',
|
||||||
|
date: '03-04-2020', // Wrong format
|
||||||
|
});
|
||||||
|
expect(result).toMatch(/Error: Invalid date format. Expected YYYY-MM-DD./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,6 +14,7 @@ const {
|
||||||
TraversaalSearch,
|
TraversaalSearch,
|
||||||
StructuredWolfram,
|
StructuredWolfram,
|
||||||
TavilySearchResults,
|
TavilySearchResults,
|
||||||
|
OpenWeather,
|
||||||
} = require('../');
|
} = require('../');
|
||||||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||||
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
||||||
|
|
@ -178,6 +179,7 @@ const loadTools = async ({
|
||||||
'azure-ai-search': StructuredACS,
|
'azure-ai-search': StructuredACS,
|
||||||
traversaal_search: TraversaalSearch,
|
traversaal_search: TraversaalSearch,
|
||||||
tavily_search_results_json: TavilySearchResults,
|
tavily_search_results_json: TavilySearchResults,
|
||||||
|
open_weather: OpenWeather,
|
||||||
};
|
};
|
||||||
|
|
||||||
const customConstructors = {
|
const customConstructors = {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const Redis = require('ioredis');
|
const Redis = require('ioredis');
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
const MemoryStore = require('memorystore')(session)
|
const MemoryStore = require('memorystore')(session);
|
||||||
const RedisStore = require('connect-redis').default;
|
const RedisStore = require('connect-redis').default;
|
||||||
const {
|
const {
|
||||||
setupOpenId,
|
setupOpenId,
|
||||||
|
|
@ -51,8 +51,8 @@ const configureSocialLogins = (app) => {
|
||||||
sessionOptions.store = new RedisStore({ client, prefix: 'librechat' });
|
sessionOptions.store = new RedisStore({ client, prefix: 'librechat' });
|
||||||
} else {
|
} else {
|
||||||
sessionOptions.store = new MemoryStore({
|
sessionOptions.store = new MemoryStore({
|
||||||
checkPeriod: 86400000 // prune expired entries every 24h
|
checkPeriod: 86400000, // prune expired entries every 24h
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
app.use(session(sessionOptions));
|
app.use(session(sessionOptions));
|
||||||
app.use(passport.session());
|
app.use(passport.session());
|
||||||
|
|
|
||||||
BIN
client/public/assets/openweather.png
Normal file
BIN
client/public/assets/openweather.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Loading…
Add table
Add a link
Reference in a new issue