tools.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. /**
  2. * Tools for the ink-chat example.
  3. *
  4. * All tools use free, public APIs — no API keys required beyond the
  5. * AI Gateway key that is already configured.
  6. *
  7. * These match the tools in examples/chat for consistency across examples.
  8. */
  9. import { tool, generateText } from "ai";
  10. import { gateway } from "@ai-sdk/gateway";
  11. import { z } from "zod";
  12. // =============================================================================
  13. // Web Search (Perplexity Sonar via AI Gateway)
  14. // =============================================================================
  15. export const webSearch = tool({
  16. description:
  17. "Search the web for current information on any topic. Returns a synthesized answer based on real-time web data.",
  18. inputSchema: z.object({
  19. query: z
  20. .string()
  21. .describe(
  22. "The search query — be specific and include relevant context for better results",
  23. ),
  24. }),
  25. execute: async ({ query }) => {
  26. try {
  27. const { text } = await generateText({
  28. model: gateway("perplexity/sonar"),
  29. prompt: query,
  30. });
  31. return { content: text };
  32. } catch (error) {
  33. return {
  34. error: `Search failed: ${error instanceof Error ? error.message : "Unknown error"}`,
  35. };
  36. }
  37. },
  38. });
  39. // =============================================================================
  40. // Weather (Open-Meteo — free, no API key)
  41. // =============================================================================
  42. function describeWeatherCode(code: number): string {
  43. const descriptions: Record<number, string> = {
  44. 0: "Clear sky",
  45. 1: "Mainly clear",
  46. 2: "Partly cloudy",
  47. 3: "Overcast",
  48. 45: "Foggy",
  49. 48: "Depositing rime fog",
  50. 51: "Light drizzle",
  51. 53: "Moderate drizzle",
  52. 55: "Dense drizzle",
  53. 61: "Slight rain",
  54. 63: "Moderate rain",
  55. 65: "Heavy rain",
  56. 71: "Slight snow",
  57. 73: "Moderate snow",
  58. 75: "Heavy snow",
  59. 80: "Slight rain showers",
  60. 81: "Moderate rain showers",
  61. 82: "Violent rain showers",
  62. 95: "Thunderstorm",
  63. };
  64. return descriptions[code] ?? "Unknown";
  65. }
  66. export const getWeather = tool({
  67. description:
  68. "Get current weather conditions and a 7-day forecast for a given city. Returns temperature, humidity, wind speed, weather conditions, and daily forecasts.",
  69. inputSchema: z.object({
  70. city: z
  71. .string()
  72. .describe("City name (e.g., 'New York', 'London', 'Tokyo')"),
  73. }),
  74. execute: async ({ city }) => {
  75. try {
  76. const geocodeUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
  77. const geocodeRes = await fetch(geocodeUrl);
  78. if (!geocodeRes.ok) {
  79. return { error: `Failed to geocode city: ${city}` };
  80. }
  81. const geocodeData = (await geocodeRes.json()) as {
  82. results?: Array<{
  83. name: string;
  84. country: string;
  85. latitude: number;
  86. longitude: number;
  87. timezone: string;
  88. }>;
  89. };
  90. if (!geocodeData.results || geocodeData.results.length === 0) {
  91. return { error: `City not found: ${city}` };
  92. }
  93. const location = geocodeData.results[0]!;
  94. const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=${encodeURIComponent(location.timezone)}&forecast_days=7`;
  95. const weatherRes = await fetch(weatherUrl);
  96. if (!weatherRes.ok) {
  97. return { error: "Failed to fetch weather data" };
  98. }
  99. const weather = (await weatherRes.json()) as {
  100. current: {
  101. temperature_2m: number;
  102. relative_humidity_2m: number;
  103. apparent_temperature: number;
  104. weather_code: number;
  105. wind_speed_10m: number;
  106. };
  107. daily: {
  108. time: string[];
  109. weather_code: number[];
  110. temperature_2m_max: number[];
  111. temperature_2m_min: number[];
  112. precipitation_sum: number[];
  113. };
  114. };
  115. const forecast = weather.daily.time.map((date, i) => ({
  116. date,
  117. day: new Date(date + "T12:00:00").toLocaleDateString("en-US", {
  118. weekday: "short",
  119. }),
  120. high: Math.round(weather.daily.temperature_2m_max[i]!),
  121. low: Math.round(weather.daily.temperature_2m_min[i]!),
  122. condition: describeWeatherCode(weather.daily.weather_code[i]!),
  123. precipitation: weather.daily.precipitation_sum[i]!,
  124. }));
  125. return {
  126. city: location.name,
  127. country: location.country,
  128. current: {
  129. temperature: Math.round(weather.current.temperature_2m),
  130. feelsLike: Math.round(weather.current.apparent_temperature),
  131. humidity: weather.current.relative_humidity_2m,
  132. windSpeed: Math.round(weather.current.wind_speed_10m),
  133. condition: describeWeatherCode(weather.current.weather_code),
  134. },
  135. forecast,
  136. };
  137. } catch (error) {
  138. return {
  139. error: `Weather fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
  140. };
  141. }
  142. },
  143. });
  144. // =============================================================================
  145. // Hacker News (Firebase API — free, no API key)
  146. // =============================================================================
  147. export const getHackerNewsTop = tool({
  148. description:
  149. "Get the current top stories from Hacker News, including title, score, author, URL, and comment count.",
  150. inputSchema: z.object({
  151. count: z
  152. .number()
  153. .min(1)
  154. .max(30)
  155. .describe("Number of top stories to fetch (1-30)"),
  156. }),
  157. execute: async ({ count }) => {
  158. try {
  159. const topRes = await fetch(
  160. "https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty",
  161. { signal: AbortSignal.timeout(5000) },
  162. );
  163. if (!topRes.ok) {
  164. return { error: "Failed to fetch Hacker News top stories" };
  165. }
  166. const topIds = (await topRes.json()) as number[];
  167. const storyIds = topIds.slice(0, count);
  168. const stories = await Promise.all(
  169. storyIds.map(async (id) => {
  170. const storyRes = await fetch(
  171. `https://hacker-news.firebaseio.com/v0/item/${id}.json?print=pretty`,
  172. { signal: AbortSignal.timeout(5000) },
  173. );
  174. if (!storyRes.ok) return null;
  175. const story = (await storyRes.json()) as {
  176. id: number;
  177. title: string;
  178. url?: string;
  179. score: number;
  180. by: string;
  181. time: number;
  182. descendants?: number;
  183. };
  184. return {
  185. title: story.title,
  186. url:
  187. story.url ?? `https://news.ycombinator.com/item?id=${story.id}`,
  188. score: story.score,
  189. author: story.by,
  190. comments: story.descendants ?? 0,
  191. };
  192. }),
  193. );
  194. return {
  195. stories: stories.filter(Boolean),
  196. fetchedAt: new Date().toISOString(),
  197. };
  198. } catch (error) {
  199. return {
  200. error: `Hacker News fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
  201. };
  202. }
  203. },
  204. });
  205. // =============================================================================
  206. // GitHub (Public API — free, no API key, 60 req/hr)
  207. // =============================================================================
  208. const ghHeaders = { Accept: "application/vnd.github.v3+json" };
  209. export const getGitHubRepo = tool({
  210. description:
  211. "Get information about a public GitHub repository including stars, forks, open issues, description, and language breakdown.",
  212. inputSchema: z.object({
  213. owner: z.string().describe("Repository owner (e.g., 'vercel')"),
  214. repo: z.string().describe("Repository name (e.g., 'next.js')"),
  215. }),
  216. execute: async ({ owner, repo }) => {
  217. try {
  218. const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
  219. const [repoRes, languagesRes] = await Promise.all([
  220. fetch(repoUrl, { headers: ghHeaders }),
  221. fetch(`${repoUrl}/languages`, { headers: ghHeaders }),
  222. ]);
  223. if (!repoRes.ok) {
  224. if (repoRes.status === 404)
  225. return { error: `Not found: ${owner}/${repo}` };
  226. return { error: `Failed to fetch repo: ${repoRes.statusText}` };
  227. }
  228. const repoData = (await repoRes.json()) as {
  229. full_name: string;
  230. description: string | null;
  231. html_url: string;
  232. stargazers_count: number;
  233. forks_count: number;
  234. open_issues_count: number;
  235. language: string | null;
  236. license: { spdx_id: string } | null;
  237. topics: string[];
  238. };
  239. const languages: Record<string, number> = languagesRes.ok
  240. ? ((await languagesRes.json()) as Record<string, number>)
  241. : {};
  242. const totalBytes = Object.values(languages).reduce((a, b) => a + b, 0);
  243. const languageBreakdown = Object.entries(languages)
  244. .map(([lang, bytes]) => ({
  245. language: lang,
  246. percentage:
  247. totalBytes > 0 ? Math.round((bytes / totalBytes) * 100) : 0,
  248. }))
  249. .sort((a, b) => b.percentage - a.percentage)
  250. .slice(0, 6);
  251. return {
  252. name: repoData.full_name,
  253. description: repoData.description,
  254. url: repoData.html_url,
  255. stars: repoData.stargazers_count,
  256. forks: repoData.forks_count,
  257. openIssues: repoData.open_issues_count,
  258. primaryLanguage: repoData.language,
  259. license: repoData.license?.spdx_id ?? "None",
  260. topics: repoData.topics,
  261. languages: languageBreakdown,
  262. };
  263. } catch (error) {
  264. return {
  265. error: `GitHub fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
  266. };
  267. }
  268. },
  269. });
  270. // =============================================================================
  271. // Crypto (CoinGecko — free, no API key)
  272. // =============================================================================
  273. export const getCryptoPrice = tool({
  274. description:
  275. "Get current price, market cap, 24h change, and 7-day trend for a cryptocurrency.",
  276. inputSchema: z.object({
  277. coinId: z
  278. .string()
  279. .describe(
  280. "CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana', 'dogecoin')",
  281. ),
  282. }),
  283. execute: async ({ coinId }) => {
  284. try {
  285. const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}?localization=false&tickers=false&community_data=false&developer_data=false&sparkline=false`;
  286. const res = await fetch(url, {
  287. headers: { Accept: "application/json" },
  288. });
  289. if (!res.ok) {
  290. if (res.status === 404)
  291. return { error: `Cryptocurrency not found: ${coinId}` };
  292. if (res.status === 429)
  293. return {
  294. error: "CoinGecko rate limit exceeded. Try again in a minute.",
  295. };
  296. return { error: `Failed to fetch crypto data: ${res.statusText}` };
  297. }
  298. const data = (await res.json()) as {
  299. id: string;
  300. symbol: string;
  301. name: string;
  302. market_data: {
  303. current_price: { usd: number };
  304. market_cap: { usd: number };
  305. total_volume: { usd: number };
  306. price_change_percentage_24h: number;
  307. price_change_percentage_7d: number;
  308. high_24h: { usd: number };
  309. low_24h: { usd: number };
  310. };
  311. market_cap_rank: number;
  312. };
  313. const md = data.market_data;
  314. return {
  315. symbol: data.symbol.toUpperCase(),
  316. name: data.name,
  317. rank: data.market_cap_rank,
  318. price: md.current_price.usd,
  319. marketCap: md.market_cap.usd,
  320. volume24h: md.total_volume.usd,
  321. change24h: Math.round(md.price_change_percentage_24h * 100) / 100,
  322. change7d: Math.round(md.price_change_percentage_7d * 100) / 100,
  323. high24h: md.high_24h.usd,
  324. low24h: md.low_24h.usd,
  325. };
  326. } catch (error) {
  327. return {
  328. error: `Crypto fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
  329. };
  330. }
  331. },
  332. });
  333. // =============================================================================
  334. // All tools (exported as a single record for streamText)
  335. // =============================================================================
  336. export const tools = {
  337. web_search: webSearch,
  338. get_weather: getWeather,
  339. get_hacker_news: getHackerNewsTop,
  340. get_github_repo: getGitHubRepo,
  341. get_crypto_price: getCryptoPrice,
  342. };