feat(app): add initial widgets

This commit is contained in:
ItzCrazyKns
2025-11-23 19:46:42 +05:30
parent d5f62f2dca
commit 4e7143ce0c
10 changed files with 1703 additions and 80 deletions

View File

@@ -1,9 +1,45 @@
import { Intent } from '../../types';
const description = `Use this intent when the user's query can be fully or partially answered using specialized widgets that provide structured, real-time data (weather, stocks, calculations, and more).
#### When to use:
1. The user is asking for specific information that a widget can provide (current weather, stock prices, mathematical calculations, unit conversions, etc.).
2. A widget can completely answer the query without needing additional web search (use this intent alone and set skipSearch to true).
3. A widget can provide part of the answer, but additional information from web search or other sources is needed (combine with other intents like 'web_search' and set skipSearch to false).
#### Example use cases:
Note: These are just examples - there are several other widgets available for use depending on the user's query.
1. "What is the weather in New York?"
- The weather widget can fully answer this query.
- Intent: ['widget_response'] with skipSearch: true
- Widget: [{ type: 'weather', location: 'New York', lat: 0, lon: 0 }]
2. "What's the weather in San Francisco today? Also tell me some popular events happening there this weekend."
- Weather widget provides current conditions, but events require web search.
- Intent: ['web_search', 'widget_response'] with skipSearch: false
- Widget: [{ type: 'weather', location: 'San Francisco', lat: 0, lon: 0 }]
3. "Calculate 25% of 480"
- The calculator widget can fully answer this.
- Intent: ['widget_response'] with skipSearch: true
- Widget: [{ type: 'calculator', expression: '25% of 480' }]
4. "AAPL stock price and recent Apple news"
- Stock widget provides price, but news requires web search.
- Intent: ['web_search', 'widget_response'] with skipSearch: false
- Widget: [{ type: 'stock', symbol: 'AAPL' }]
5. "What's Tesla's stock doing and how does it compare to competitors?"
- Stock widget provides Tesla's price, but comparison analysis requires web search.
- Intent: ['web_search', 'widget_response'] with skipSearch: false
- Widget: [{ type: 'stock', symbol: 'TSLA' }]
**IMPORTANT**: Set skipSearch to true ONLY if the widget(s) can completely answer the user's query without any additional information. If the user asks for anything beyond what the widget provides (context, explanations, comparisons, related information), combine this intent with 'web_search' and set skipSearch to false.`;
const widgetResponseIntent: Intent = {
name: 'widget_response',
description:
'Use this intent to respond to user queries using available widgets when the required information can be obtained from them.',
description,
requiresSearch: false,
enabled: (config) => true,
};

View File

@@ -0,0 +1,65 @@
import z from 'zod';
import { Widget } from '../types';
import { evaluate as mathEval } from 'mathjs';
const schema = z.object({
type: z.literal('calculation'),
expression: z
.string()
.describe(
"A valid mathematical expression to be evaluated (e.g., '2 + 2', '3 * (4 + 5)').",
),
});
const calculationWidget: Widget<typeof schema> = {
name: 'calculation',
description: `Performs mathematical calculations and evaluates mathematical expressions. Supports arithmetic operations, algebraic equations, functions, and complex mathematical computations.
**What it provides:**
- Evaluates mathematical expressions and returns computed results
- Handles basic arithmetic (+, -, *, /)
- Supports functions (sqrt, sin, cos, log, etc.)
- Can process complex expressions with parentheses and order of operations
**When to use:**
- User asks to calculate, compute, or evaluate a mathematical expression
- Questions like "what is X", "calculate Y", "how much is Z" where X/Y/Z are math expressions
- Any request involving numbers and mathematical operations
**Example call:**
{
"type": "calculation",
"expression": "25% of 480"
}
{
"type": "calculation",
"expression": "sqrt(144) + 5 * 2"
}
**Important:** The expression must be valid mathematical syntax that can be evaluated by mathjs. Format percentages as "0.25 * 480" or "25% of 480". Do not include currency symbols, units, or non-mathematical text in the expression.`,
schema: schema,
execute: async (params, _) => {
try {
const result = mathEval(params.expression);
return {
type: 'calculation_result',
data: {
expression: params.expression,
result: result,
},
};
} catch (error) {
return {
type: 'calculation_result',
data: {
expression: params.expression,
result: `Error evaluating expression: ${error}`,
},
};
}
},
};
export default calculationWidget;

View File

@@ -1,6 +1,10 @@
import calculationWidget from './calculationWidget';
import WidgetRegistry from './registry';
import weatherWidget from './weatherWidget';
import stockWidget from './stockWidget';
WidgetRegistry.register(weatherWidget);
WidgetRegistry.register(calculationWidget);
WidgetRegistry.register(stockWidget);
export { WidgetRegistry };

View File

@@ -0,0 +1,412 @@
import z from 'zod';
import { Widget } from '../types';
import YahooFinance from 'yahoo-finance2';
const yf = new YahooFinance({
suppressNotices: ['yahooSurvey'],
});
const schema = z.object({
type: z.literal('stock'),
ticker: z
.string()
.describe(
"The stock ticker symbol in uppercase (e.g., 'AAPL' for Apple Inc., 'TSLA' for Tesla, 'GOOGL' for Google). Use the primary exchange ticker.",
),
comparisonTickers: z
.array(z.string())
.max(3)
.describe(
"Optional array of up to 3 ticker symbols to compare against the base ticker (e.g., ['MSFT', 'GOOGL', 'META']). Charts will show percentage change comparison.",
),
});
const stockWidget: Widget<typeof schema> = {
name: 'stock',
description: `Provides comprehensive real-time stock market data and financial information for any publicly traded company. Returns detailed quote data, market status, trading metrics, and company fundamentals.
You can set skipSearch to true if the stock widget can fully answer the user's query without needing additional web search.
**What it provides:**
- **Real-time Price Data**: Current price, previous close, open price, day's range (high/low)
- **Market Status**: Whether market is currently open or closed, trading sessions
- **Trading Metrics**: Volume, average volume, bid/ask prices and sizes
- **Performance**: Price changes (absolute and percentage), 52-week high/low range
- **Valuation**: Market capitalization, P/E ratio, earnings per share (EPS)
- **Dividends**: Dividend rate, dividend yield, ex-dividend date
- **Company Info**: Full company name, exchange, currency, sector/industry (when available)
- **Advanced Metrics**: Beta, trailing/forward P/E, book value, price-to-book ratio
- **Charts Data**: Historical price movements for visualization
- **Comparison**: Compare up to 3 stocks side-by-side with percentage-based performance visualization
**When to use:**
- User asks about a stock price ("What's AAPL stock price?", "How is Tesla doing?")
- Questions about company market performance ("Is Microsoft up or down today?")
- Requests for stock market data, trading info, or company valuation
- Queries about dividends, P/E ratio, market cap, or other financial metrics
- Any stock/equity-related question for a specific company
- Stock comparisons ("Compare AAPL vs MSFT", "How is TSLA doing vs RIVN and LCID?")
**Example calls:**
{
"type": "stock",
"ticker": "AAPL"
}
{
"type": "stock",
"ticker": "TSLA",
"comparisonTickers": ["RIVN", "LCID"]
}
{
"type": "stock",
"ticker": "GOOGL",
"comparisonTickers": ["MSFT", "META", "AMZN"]
}
**Important:**
- Use the correct ticker symbol (uppercase preferred: AAPL not aapl)
- For companies with multiple share classes, use the most common one (e.g., GOOGL for Google Class A shares)
- The widget works for stocks listed on major exchanges (NYSE, NASDAQ, etc.)
- Returns comprehensive data; the UI will display relevant metrics based on availability
- Market data may be delayed by 15-20 minutes for free data sources during trading hours`,
schema: schema,
execute: async (params, _) => {
try {
const ticker = params.ticker.toUpperCase();
const quote: any = await yf.quote(ticker);
const chartPromises = {
'1D': yf
.chart(ticker, {
period1: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
period2: new Date(),
interval: '5m',
})
.catch(() => null),
'5D': yf
.chart(ticker, {
period1: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000),
period2: new Date(),
interval: '15m',
})
.catch(() => null),
'1M': yf
.chart(ticker, {
period1: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
'3M': yf
.chart(ticker, {
period1: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
'6M': yf
.chart(ticker, {
period1: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
'1Y': yf
.chart(ticker, {
period1: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
MAX: yf
.chart(ticker, {
period1: new Date(Date.now() - 10 * 365 * 24 * 60 * 60 * 1000),
interval: '1wk',
})
.catch(() => null),
};
const charts = await Promise.all([
chartPromises['1D'],
chartPromises['5D'],
chartPromises['1M'],
chartPromises['3M'],
chartPromises['6M'],
chartPromises['1Y'],
chartPromises['MAX'],
]);
const [chart1D, chart5D, chart1M, chart3M, chart6M, chart1Y, chartMAX] =
charts;
if (!quote) {
throw new Error(`No data found for ticker: ${ticker}`);
}
let comparisonData: any = null;
if (params.comparisonTickers.length > 0) {
const comparisonPromises = params.comparisonTickers
.slice(0, 3)
.map(async (compTicker) => {
try {
const compQuote = await yf.quote(compTicker);
const compCharts = await Promise.all([
yf
.chart(compTicker, {
period1: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
period2: new Date(),
interval: '5m',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000),
period2: new Date(),
interval: '15m',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
interval: '1d',
})
.catch(() => null),
yf
.chart(compTicker, {
period1: new Date(
Date.now() - 10 * 365 * 24 * 60 * 60 * 1000,
),
interval: '1wk',
})
.catch(() => null),
]);
return {
ticker: compTicker,
name: compQuote.shortName || compTicker,
charts: compCharts,
};
} catch (error) {
console.error(
`Failed to fetch comparison ticker ${compTicker}:`,
error,
);
return null;
}
});
const compResults = await Promise.all(comparisonPromises);
comparisonData = compResults.filter((r) => r !== null);
}
const stockData = {
symbol: quote.symbol,
shortName: quote.shortName || quote.longName || ticker,
longName: quote.longName,
exchange: quote.fullExchangeName || quote.exchange,
currency: quote.currency,
quoteType: quote.quoteType,
marketState: quote.marketState,
regularMarketTime: quote.regularMarketTime,
postMarketTime: quote.postMarketTime,
preMarketTime: quote.preMarketTime,
regularMarketPrice: quote.regularMarketPrice,
regularMarketChange: quote.regularMarketChange,
regularMarketChangePercent: quote.regularMarketChangePercent,
regularMarketPreviousClose: quote.regularMarketPreviousClose,
regularMarketOpen: quote.regularMarketOpen,
regularMarketDayHigh: quote.regularMarketDayHigh,
regularMarketDayLow: quote.regularMarketDayLow,
postMarketPrice: quote.postMarketPrice,
postMarketChange: quote.postMarketChange,
postMarketChangePercent: quote.postMarketChangePercent,
preMarketPrice: quote.preMarketPrice,
preMarketChange: quote.preMarketChange,
preMarketChangePercent: quote.preMarketChangePercent,
regularMarketVolume: quote.regularMarketVolume,
averageDailyVolume3Month: quote.averageDailyVolume3Month,
averageDailyVolume10Day: quote.averageDailyVolume10Day,
bid: quote.bid,
bidSize: quote.bidSize,
ask: quote.ask,
askSize: quote.askSize,
fiftyTwoWeekLow: quote.fiftyTwoWeekLow,
fiftyTwoWeekHigh: quote.fiftyTwoWeekHigh,
fiftyTwoWeekChange: quote.fiftyTwoWeekChange,
fiftyTwoWeekChangePercent: quote.fiftyTwoWeekChangePercent,
marketCap: quote.marketCap,
trailingPE: quote.trailingPE,
forwardPE: quote.forwardPE,
priceToBook: quote.priceToBook,
bookValue: quote.bookValue,
earningsPerShare: quote.epsTrailingTwelveMonths,
epsForward: quote.epsForward,
dividendRate: quote.dividendRate,
dividendYield: quote.dividendYield,
exDividendDate: quote.exDividendDate,
trailingAnnualDividendRate: quote.trailingAnnualDividendRate,
trailingAnnualDividendYield: quote.trailingAnnualDividendYield,
beta: quote.beta,
fiftyDayAverage: quote.fiftyDayAverage,
fiftyDayAverageChange: quote.fiftyDayAverageChange,
fiftyDayAverageChangePercent: quote.fiftyDayAverageChangePercent,
twoHundredDayAverage: quote.twoHundredDayAverage,
twoHundredDayAverageChange: quote.twoHundredDayAverageChange,
twoHundredDayAverageChangePercent:
quote.twoHundredDayAverageChangePercent,
sector: quote.sector,
industry: quote.industry,
website: quote.website,
chartData: {
'1D': chart1D
? {
timestamps: chart1D.quotes.map((q: any) => q.date.getTime()),
prices: chart1D.quotes.map((q: any) => q.close),
}
: null,
'5D': chart5D
? {
timestamps: chart5D.quotes.map((q: any) => q.date.getTime()),
prices: chart5D.quotes.map((q: any) => q.close),
}
: null,
'1M': chart1M
? {
timestamps: chart1M.quotes.map((q: any) => q.date.getTime()),
prices: chart1M.quotes.map((q: any) => q.close),
}
: null,
'3M': chart3M
? {
timestamps: chart3M.quotes.map((q: any) => q.date.getTime()),
prices: chart3M.quotes.map((q: any) => q.close),
}
: null,
'6M': chart6M
? {
timestamps: chart6M.quotes.map((q: any) => q.date.getTime()),
prices: chart6M.quotes.map((q: any) => q.close),
}
: null,
'1Y': chart1Y
? {
timestamps: chart1Y.quotes.map((q: any) => q.date.getTime()),
prices: chart1Y.quotes.map((q: any) => q.close),
}
: null,
MAX: chartMAX
? {
timestamps: chartMAX.quotes.map((q: any) => q.date.getTime()),
prices: chartMAX.quotes.map((q: any) => q.close),
}
: null,
},
comparisonData: comparisonData
? comparisonData.map((comp: any) => ({
ticker: comp.ticker,
name: comp.name,
chartData: {
'1D': comp.charts[0]
? {
timestamps: comp.charts[0].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[0].quotes.map((q: any) => q.close),
}
: null,
'5D': comp.charts[1]
? {
timestamps: comp.charts[1].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[1].quotes.map((q: any) => q.close),
}
: null,
'1M': comp.charts[2]
? {
timestamps: comp.charts[2].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[2].quotes.map((q: any) => q.close),
}
: null,
'3M': comp.charts[3]
? {
timestamps: comp.charts[3].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[3].quotes.map((q: any) => q.close),
}
: null,
'6M': comp.charts[4]
? {
timestamps: comp.charts[4].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[4].quotes.map((q: any) => q.close),
}
: null,
'1Y': comp.charts[5]
? {
timestamps: comp.charts[5].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[5].quotes.map((q: any) => q.close),
}
: null,
MAX: comp.charts[6]
? {
timestamps: comp.charts[6].quotes.map((q: any) =>
q.date.getTime(),
),
prices: comp.charts[6].quotes.map((q: any) => q.close),
}
: null,
},
}))
: null,
};
return {
type: 'stock',
data: stockData,
};
} catch (error: any) {
return {
type: 'stock',
data: {
error: `Error fetching stock data: ${error.message || error}`,
ticker: params.ticker,
},
};
}
},
};
export default stockWidget;

View File

@@ -20,104 +20,155 @@ const WeatherWidgetSchema = z.object({
),
});
const weatherWidget = {
const weatherWidget: Widget<typeof WeatherWidgetSchema> = {
name: 'weather',
description:
'Provides current weather information for a specified location. It can return details such as temperature, humidity, wind speed, and weather conditions. It needs either a location name or latitude/longitude coordinates to function.',
description: `Provides comprehensive current weather information and forecasts for any location worldwide. Returns real-time weather data including temperature, conditions, humidity, wind, and multi-day forecasts.
You can set skipSearch to true if the weather widget can fully answer the user's query without needing additional web search.
**What it provides:**
- Current weather conditions (temperature, feels-like, humidity, precipitation)
- Wind speed, direction, and gusts
- Weather codes/conditions (clear, cloudy, rainy, etc.)
- Hourly forecast for next 24 hours
- Daily forecast for next 7 days (high/low temps, precipitation probability)
- Timezone information
**When to use:**
- User asks about weather in a location ("weather in X", "is it raining in Y")
- Questions about temperature, conditions, or forecast
- Any weather-related query for a specific place
**Example call:**
{
"type": "weather",
"location": "San Francisco, CA, USA",
"lat": 0,
"lon": 0
}
**Important:** Provide EITHER a location name OR latitude/longitude coordinates, never both. If using location name, set lat/lon to 0. Location should be specific (city, state/region, country) for best results.`,
schema: WeatherWidgetSchema,
execute: async (params, _) => {
if (
params.location === '' &&
(params.lat === undefined || params.lon === undefined)
) {
throw new Error(
'Either location name or both latitude and longitude must be provided.',
);
}
if (params.location !== '') {
const openStreetMapUrl = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(params.location)}&format=json&limit=1`;
const locationRes = await fetch(openStreetMapUrl, {
headers: {
'User-Agent': 'Perplexica',
'Content-Type': 'application/json',
},
});
const data = await locationRes.json();
const location = data[0];
if (!location) {
try {
if (
params.location === '' &&
(params.lat === undefined || params.lon === undefined)
) {
throw new Error(
`Could not find coordinates for location: ${params.location}`,
'Either location name or both latitude and longitude must be provided.',
);
}
const weatherRes = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lon}&current_weather=true`,
{
if (params.location !== '') {
const openStreetMapUrl = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(params.location)}&format=json&limit=1`;
const locationRes = await fetch(openStreetMapUrl, {
headers: {
'User-Agent': 'Perplexica',
'Content-Type': 'application/json',
},
},
);
});
const weatherData = await weatherRes.json();
const data = await locationRes.json();
const location = data[0];
if (!location) {
throw new Error(
`Could not find coordinates for location: ${params.location}`,
);
}
const weatherRes = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lon}&current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7`,
{
headers: {
'User-Agent': 'Perplexica',
'Content-Type': 'application/json',
},
},
);
const weatherData = await weatherRes.json();
return {
type: 'weather',
data: {
location: params.location,
latitude: location.lat,
longitude: location.lon,
current: weatherData.current,
hourly: {
time: weatherData.hourly.time.slice(0, 24),
temperature_2m: weatherData.hourly.temperature_2m.slice(0, 24),
precipitation_probability:
weatherData.hourly.precipitation_probability.slice(0, 24),
precipitation: weatherData.hourly.precipitation.slice(0, 24),
weather_code: weatherData.hourly.weather_code.slice(0, 24),
},
daily: weatherData.daily,
timezone: weatherData.timezone,
},
};
} else if (params.lat !== undefined && params.lon !== undefined) {
const [weatherRes, locationRes] = await Promise.all([
fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${params.lat}&longitude=${params.lon}&current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7`,
{
headers: {
'User-Agent': 'Perplexica',
'Content-Type': 'application/json',
},
},
),
fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${params.lat}&lon=${params.lon}&format=json`,
{
headers: {
'User-Agent': 'Perplexica',
'Content-Type': 'application/json',
},
},
),
]);
const weatherData = await weatherRes.json();
const locationData = await locationRes.json();
return {
type: 'weather',
data: {
location: locationData.display_name,
latitude: params.lat,
longitude: params.lon,
current: weatherData.current,
hourly: {
time: weatherData.hourly.time.slice(0, 24),
temperature_2m: weatherData.hourly.temperature_2m.slice(0, 24),
precipitation_probability:
weatherData.hourly.precipitation_probability.slice(0, 24),
precipitation: weatherData.hourly.precipitation.slice(0, 24),
weather_code: weatherData.hourly.weather_code.slice(0, 24),
},
daily: weatherData.daily,
timezone: weatherData.timezone,
},
};
}
/* this is like a very simple implementation just to see the bacckend works, when we're working on the frontend, we'll return more data i guess? */
return {
type: 'weather',
data: {
location: params.location,
latitude: location.lat,
longitude: location.lon,
weather: weatherData.current_weather,
},
data: null,
};
} else if (params.lat !== undefined && params.lon !== undefined) {
const [weatherRes, locationRes] = await Promise.all([
fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${params.lat}&longitude=${params.lon}&current_weather=true`,
{
headers: {
'User-Agent': 'Perplexica',
'Content-Type': 'application/json',
},
},
),
fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${params.lat}&lon=${params.lon}&format=json`,
{
headers: {
'User-Agent': 'Perplexica',
'Content-Type': 'application/json',
},
},
),
]);
const weatherData = await weatherRes.json();
const locationData = await locationRes.json();
} catch (err) {
return {
type: 'weather',
data: {
location: locationData.display_name,
latitude: params.lat,
longitude: params.lon,
weather: weatherData.current_weather,
error: `Error fetching weather data: ${err}`,
},
};
}
return {
type: 'weather',
data: null,
};
},
} satisfies Widget<typeof WeatherWidgetSchema>;
};
export default weatherWidget;