1163 lines
44 KiB
TypeScript
1163 lines
44 KiB
TypeScript
// /app/api/chat/route.ts
|
|
import { z } from "zod";
|
|
import { xai } from '@ai-sdk/xai'
|
|
import Exa from 'exa-js'
|
|
import {
|
|
convertToCoreMessages,
|
|
streamText,
|
|
tool,
|
|
smoothStream
|
|
} from "ai";
|
|
import { BlobRequestAbortedError, put } from '@vercel/blob';
|
|
import CodeInterpreter from "@e2b/code-interpreter";
|
|
import FirecrawlApp from '@mendable/firecrawl-js';
|
|
import { tavily } from '@tavily/core'
|
|
import { getGroupConfig } from "@/app/actions";
|
|
import { geolocation, ipAddress } from '@vercel/functions'
|
|
import { Ratelimit } from "@upstash/ratelimit"; // for deno: see above
|
|
import { Redis } from "@upstash/redis"; // see below for cloudflare and fastly adapters
|
|
|
|
// Allow streaming responses up to 60 seconds
|
|
export const maxDuration = 120;
|
|
|
|
interface XResult {
|
|
id: string;
|
|
url: string;
|
|
title: string;
|
|
author?: string;
|
|
publishedDate?: string;
|
|
text: string;
|
|
highlights?: string[];
|
|
tweetId: string;
|
|
}
|
|
|
|
interface MapboxFeature {
|
|
id: string;
|
|
name: string;
|
|
formatted_address: string;
|
|
geometry: {
|
|
type: string;
|
|
coordinates: number[];
|
|
};
|
|
feature_type: string;
|
|
context: string;
|
|
coordinates: number[];
|
|
bbox: number[];
|
|
source: string;
|
|
}
|
|
|
|
|
|
interface GoogleResult {
|
|
place_id: string;
|
|
formatted_address: string;
|
|
geometry: {
|
|
location: {
|
|
lat: number;
|
|
lng: number;
|
|
};
|
|
viewport: {
|
|
northeast: {
|
|
lat: number;
|
|
lng: number;
|
|
};
|
|
southwest: {
|
|
lat: number;
|
|
lng: number;
|
|
};
|
|
};
|
|
};
|
|
types: string[];
|
|
address_components: Array<{
|
|
long_name: string;
|
|
short_name: string;
|
|
types: string[];
|
|
}>;
|
|
}
|
|
|
|
interface VideoDetails {
|
|
title?: string;
|
|
author_name?: string;
|
|
author_url?: string;
|
|
thumbnail_url?: string;
|
|
type?: string;
|
|
provider_name?: string;
|
|
provider_url?: string;
|
|
}
|
|
|
|
interface VideoResult {
|
|
videoId: string;
|
|
url: string;
|
|
details?: VideoDetails;
|
|
captions?: string;
|
|
timestamps?: string[];
|
|
views?: string;
|
|
likes?: string;
|
|
summary?: string;
|
|
}
|
|
|
|
function sanitizeUrl(url: string): string {
|
|
return url.replace(/\s+/g, '%20')
|
|
}
|
|
|
|
async function isValidImageUrl(url: string): Promise<boolean> {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
|
|
const response = await fetch(url, {
|
|
method: 'HEAD',
|
|
signal: controller.signal
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
|
|
return response.ok && (response.headers.get('content-type')?.startsWith('image/') ?? false);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const ratelimit = new Ratelimit({
|
|
redis: Redis.fromEnv(),
|
|
limiter: Ratelimit.slidingWindow(100, "1 d"),
|
|
analytics: true,
|
|
prefix: "mplx",
|
|
});
|
|
|
|
export async function POST(req: Request) {
|
|
const { messages, model, group } = await req.json();
|
|
const { tools: activeTools, systemPrompt } = await getGroupConfig(group);
|
|
|
|
// const identifier = ipAddress(req) || "api";
|
|
// const { success } = await ratelimit.limit(identifier);
|
|
|
|
// if (!success) {
|
|
// return new Response("Rate limit exceeded for 100 searches a day.", { status: 429 });
|
|
// }
|
|
|
|
const result = streamText({
|
|
model: xai(model),
|
|
messages: convertToCoreMessages(messages),
|
|
experimental_transform: smoothStream({
|
|
delayInMs: 15,
|
|
}),
|
|
experimental_activeTools: [...activeTools],
|
|
system: systemPrompt,
|
|
tools: {
|
|
thinking_canvas: tool({
|
|
description: "Write your plan of action in a canvas based on the user's input.",
|
|
parameters: z.object({
|
|
title: z.string().describe("The title of the canvas."),
|
|
content: z.array(z.string()).describe("The content of the canvas."),
|
|
}),
|
|
execute: async ({ title, content }: { title: string, content: string[] }) => { return { title, content }; },
|
|
}),
|
|
web_search: tool({
|
|
description: "Search the web for information with multiple queries, max results and search depth.",
|
|
parameters: z.object({
|
|
queries: z.array(z.string().describe("Array of search queries to look up on the web.")),
|
|
maxResults: z.array(z
|
|
.number()
|
|
.describe("Array of maximum number of results to return per query.").default(10)),
|
|
topics: z.array(z
|
|
.enum(["general", "news"])
|
|
.describe("Array of topic types to search for.").default("general")),
|
|
searchDepth: z.array(z
|
|
.enum(["basic", "advanced"])
|
|
.describe("Array of search depths to use.").default("basic")),
|
|
exclude_domains: z
|
|
.array(z.string())
|
|
.describe("A list of domains to exclude from all search results.").default([]),
|
|
}),
|
|
execute: async ({
|
|
queries,
|
|
maxResults,
|
|
topics,
|
|
searchDepth,
|
|
exclude_domains,
|
|
}: {
|
|
queries: string[];
|
|
maxResults: number[];
|
|
topics: ("general" | "news")[];
|
|
searchDepth: ("basic" | "advanced")[];
|
|
exclude_domains?: string[];
|
|
}) => {
|
|
const apiKey = process.env.TAVILY_API_KEY;
|
|
const tvly = tavily({ apiKey });
|
|
const includeImageDescriptions = true;
|
|
|
|
console.log("Queries:", queries);
|
|
console.log("Max Results:", maxResults);
|
|
console.log("Topics:", topics);
|
|
console.log("Search Depths:", searchDepth);
|
|
console.log("Exclude Domains:", exclude_domains);
|
|
|
|
// Execute searches in parallel
|
|
const searchPromises = queries.map(async (query, index) => {
|
|
const data = await tvly.search(query, {
|
|
topic: topics[index] || topics[0] || "general",
|
|
days: topics[index] === "news" ? 7 : undefined,
|
|
maxResults: maxResults[index] || maxResults[0] || 10,
|
|
searchDepth: searchDepth[index] || searchDepth[0] || "basic",
|
|
includeAnswer: true,
|
|
includeImages: true,
|
|
includeImageDescriptions: includeImageDescriptions,
|
|
excludeDomains: exclude_domains,
|
|
});
|
|
|
|
return {
|
|
query,
|
|
results: data.results.map((obj: any) => ({
|
|
url: obj.url,
|
|
title: obj.title,
|
|
content: obj.content,
|
|
raw_content: obj.raw_content,
|
|
published_date: topics[index] === "news" ? obj.published_date : undefined,
|
|
})),
|
|
images: includeImageDescriptions
|
|
? await Promise.all(
|
|
data.images
|
|
.map(async ({ url, description }: { url: string; description?: string }) => {
|
|
const sanitizedUrl = sanitizeUrl(url);
|
|
const isValid = await isValidImageUrl(sanitizedUrl);
|
|
|
|
return isValid ? {
|
|
url: sanitizedUrl,
|
|
description: description ?? ''
|
|
} : null;
|
|
})
|
|
).then(results =>
|
|
results.filter((image): image is { url: string; description: string } =>
|
|
image !== null &&
|
|
typeof image === 'object' &&
|
|
typeof image.description === 'string' &&
|
|
image.description !== ''
|
|
)
|
|
)
|
|
: await Promise.all(
|
|
data.images
|
|
.map(async ({ url }: { url: string }) => {
|
|
const sanitizedUrl = sanitizeUrl(url);
|
|
return await isValidImageUrl(sanitizedUrl) ? sanitizedUrl : null;
|
|
})
|
|
).then(results => results.filter((url): url is string => url !== null))
|
|
};
|
|
});
|
|
|
|
const searchResults = await Promise.all(searchPromises);
|
|
|
|
return {
|
|
searches: searchResults,
|
|
};
|
|
},
|
|
}),
|
|
x_search: tool({
|
|
description: "Search X (formerly Twitter) posts.",
|
|
parameters: z.object({
|
|
query: z.string().describe("The search query"),
|
|
}),
|
|
execute: async ({ query }: { query: string }) => {
|
|
try {
|
|
const exa = new Exa(process.env.EXA_API_KEY as string);
|
|
|
|
const result = await exa.searchAndContents(
|
|
query,
|
|
{
|
|
type: "keyword",
|
|
numResults: 10,
|
|
includeDomains: ["x.com", "twitter.com"],
|
|
text: true,
|
|
highlights: true
|
|
}
|
|
);
|
|
|
|
// Extract tweet ID from URL
|
|
const extractTweetId = (url: string): string | null => {
|
|
const match = url.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
|
|
return match ? match[1] : null;
|
|
};
|
|
|
|
// Process and filter results
|
|
const processedResults = result.results.reduce<Array<XResult>>((acc, post) => {
|
|
const tweetId = extractTweetId(post.url);
|
|
if (tweetId) {
|
|
acc.push({
|
|
...post,
|
|
tweetId,
|
|
title: post.title || ""
|
|
});
|
|
}
|
|
return acc;
|
|
}, []);
|
|
|
|
return processedResults;
|
|
|
|
} catch (error) {
|
|
console.error("X search error:", error);
|
|
throw error;
|
|
}
|
|
},
|
|
}),
|
|
tmdb_search: tool({
|
|
description: "Search for a movie or TV show using TMDB API",
|
|
parameters: z.object({
|
|
query: z.string().describe("The search query for movies/TV shows"),
|
|
}),
|
|
execute: async ({ query }: { query: string }) => {
|
|
const TMDB_API_KEY = process.env.TMDB_API_KEY;
|
|
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
|
|
|
try {
|
|
// First do a multi-search to get the top result
|
|
const searchResponse = await fetch(
|
|
`${TMDB_BASE_URL}/search/multi?query=${encodeURIComponent(query)}&include_adult=true&language=en-US&page=1`,
|
|
{
|
|
headers: {
|
|
'Authorization': `Bearer ${TMDB_API_KEY}`,
|
|
'accept': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
|
|
const searchResults = await searchResponse.json();
|
|
|
|
// Get the first movie or TV show result
|
|
const firstResult = searchResults.results.find(
|
|
(result: any) => result.media_type === 'movie' || result.media_type === 'tv'
|
|
);
|
|
|
|
if (!firstResult) {
|
|
return { result: null };
|
|
}
|
|
|
|
// Get detailed information for the media
|
|
const detailsResponse = await fetch(
|
|
`${TMDB_BASE_URL}/${firstResult.media_type}/${firstResult.id}?language=en-US`,
|
|
{
|
|
headers: {
|
|
'Authorization': `Bearer ${TMDB_API_KEY}`,
|
|
'accept': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
|
|
const details = await detailsResponse.json();
|
|
|
|
// Get additional credits information
|
|
const creditsResponse = await fetch(
|
|
`${TMDB_BASE_URL}/${firstResult.media_type}/${firstResult.id}/credits?language=en-US`,
|
|
{
|
|
headers: {
|
|
'Authorization': `Bearer ${TMDB_API_KEY}`,
|
|
'accept': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
|
|
const credits = await creditsResponse.json();
|
|
|
|
// Format the result
|
|
const result = {
|
|
...details,
|
|
media_type: firstResult.media_type,
|
|
credits: {
|
|
cast: credits.cast?.slice(0, 5).map((person: any) => ({
|
|
...person,
|
|
profile_path: person.profile_path ?
|
|
`https://image.tmdb.org/t/p/original${person.profile_path}` : null
|
|
})) || [],
|
|
director: credits.crew?.find((person: any) => person.job === 'Director')?.name,
|
|
writer: credits.crew?.find((person: any) =>
|
|
person.job === 'Screenplay' || person.job === 'Writer'
|
|
)?.name,
|
|
},
|
|
poster_path: details.poster_path ?
|
|
`https://image.tmdb.org/t/p/original${details.poster_path}` : null,
|
|
backdrop_path: details.backdrop_path ?
|
|
`https://image.tmdb.org/t/p/original${details.backdrop_path}` : null,
|
|
};
|
|
|
|
return { result };
|
|
|
|
} catch (error) {
|
|
console.error("TMDB search error:", error);
|
|
throw error;
|
|
}
|
|
},
|
|
}),
|
|
trending_movies: tool({
|
|
description: "Get trending movies from TMDB",
|
|
parameters: z.object({}),
|
|
execute: async () => {
|
|
const TMDB_API_KEY = process.env.TMDB_API_KEY;
|
|
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`${TMDB_BASE_URL}/trending/movie/day?language=en-US`,
|
|
{
|
|
headers: {
|
|
'Authorization': `Bearer ${TMDB_API_KEY}`,
|
|
'accept': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
|
|
const data = await response.json();
|
|
const results = data.results.map((movie: any) => ({
|
|
...movie,
|
|
poster_path: movie.poster_path ?
|
|
`https://image.tmdb.org/t/p/original${movie.poster_path}` : null,
|
|
backdrop_path: movie.backdrop_path ?
|
|
`https://image.tmdb.org/t/p/original${movie.backdrop_path}` : null,
|
|
}));
|
|
|
|
return { results };
|
|
} catch (error) {
|
|
console.error("Trending movies error:", error);
|
|
throw error;
|
|
}
|
|
},
|
|
}),
|
|
trending_tv: tool({
|
|
description: "Get trending TV shows from TMDB",
|
|
parameters: z.object({}),
|
|
execute: async () => {
|
|
const TMDB_API_KEY = process.env.TMDB_API_KEY;
|
|
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`${TMDB_BASE_URL}/trending/tv/day?language=en-US`,
|
|
{
|
|
headers: {
|
|
'Authorization': `Bearer ${TMDB_API_KEY}`,
|
|
'accept': 'application/json'
|
|
}
|
|
}
|
|
);
|
|
|
|
const data = await response.json();
|
|
const results = data.results.map((show: any) => ({
|
|
...show,
|
|
poster_path: show.poster_path ?
|
|
`https://image.tmdb.org/t/p/original${show.poster_path}` : null,
|
|
backdrop_path: show.backdrop_path ?
|
|
`https://image.tmdb.org/t/p/original${show.backdrop_path}` : null,
|
|
}));
|
|
|
|
return { results };
|
|
} catch (error) {
|
|
console.error("Trending TV shows error:", error);
|
|
throw error;
|
|
}
|
|
},
|
|
}),
|
|
academic_search: tool({
|
|
description: "Search academic papers and research.",
|
|
parameters: z.object({
|
|
query: z.string().describe("The search query"),
|
|
}),
|
|
execute: async ({ query }: { query: string }) => {
|
|
try {
|
|
const exa = new Exa(process.env.EXA_API_KEY as string);
|
|
|
|
// Search academic papers with content summary
|
|
const result = await exa.searchAndContents(
|
|
query,
|
|
{
|
|
type: "auto",
|
|
numResults: 20,
|
|
category: "research paper",
|
|
summary: {
|
|
query: "Abstract of the Paper"
|
|
}
|
|
}
|
|
);
|
|
|
|
// Process and clean results
|
|
const processedResults = result.results.reduce<typeof result.results>((acc, paper) => {
|
|
// Skip if URL already exists or if no summary available
|
|
if (acc.some(p => p.url === paper.url) || !paper.summary) return acc;
|
|
|
|
// Clean up summary (remove "Summary:" prefix if exists)
|
|
const cleanSummary = paper.summary.replace(/^Summary:\s*/i, '');
|
|
|
|
// Clean up title (remove [...] suffixes)
|
|
const cleanTitle = paper.title?.replace(/\s\[.*?\]$/, '');
|
|
|
|
acc.push({
|
|
...paper,
|
|
title: cleanTitle || "",
|
|
summary: cleanSummary,
|
|
});
|
|
|
|
return acc;
|
|
}, []);
|
|
|
|
// Take only the first 10 unique, valid results
|
|
const limitedResults = processedResults.slice(0, 10);
|
|
|
|
return {
|
|
results: limitedResults
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error("Academic search error:", error);
|
|
throw error;
|
|
}
|
|
},
|
|
}),
|
|
youtube_search: tool({
|
|
description: "Search YouTube videos using Exa AI and get detailed video information.",
|
|
parameters: z.object({
|
|
query: z.string().describe("The search query for YouTube videos"),
|
|
no_of_results: z.number().default(5).describe("The number of results to return"),
|
|
}),
|
|
execute: async ({ query, no_of_results }: { query: string, no_of_results: number }) => {
|
|
try {
|
|
const exa = new Exa(process.env.EXA_API_KEY as string);
|
|
|
|
// Simple search to get YouTube URLs only
|
|
const searchResult = await exa.search(
|
|
query,
|
|
{
|
|
type: "keyword",
|
|
numResults: no_of_results,
|
|
includeDomains: ["youtube.com"]
|
|
}
|
|
);
|
|
|
|
// Process results
|
|
const processedResults = await Promise.all(
|
|
searchResult.results.map(async (result): Promise<VideoResult | null> => {
|
|
const videoIdMatch = result.url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&?/]+)/);
|
|
const videoId = videoIdMatch?.[1];
|
|
|
|
if (!videoId) return null;
|
|
|
|
// Base result
|
|
const baseResult: VideoResult = {
|
|
videoId,
|
|
url: result.url
|
|
};
|
|
|
|
try {
|
|
// Fetch detailed info from our endpoints
|
|
const [detailsResponse, captionsResponse, timestampsResponse] = await Promise.all([
|
|
fetch(`${process.env.YT_ENDPOINT}/video-data`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: result.url })
|
|
}).then(res => res.ok ? res.json() : null),
|
|
fetch(`${process.env.YT_ENDPOINT}/video-captions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: result.url })
|
|
}).then(res => res.ok ? res.text() : null),
|
|
fetch(`${process.env.YT_ENDPOINT}/video-timestamps`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: result.url })
|
|
}).then(res => res.ok ? res.json() : null)
|
|
]);
|
|
|
|
// Return combined data
|
|
return {
|
|
...baseResult,
|
|
details: detailsResponse || undefined,
|
|
captions: captionsResponse || undefined,
|
|
timestamps: timestampsResponse || undefined,
|
|
};
|
|
} catch (error) {
|
|
console.error(`Error fetching details for video ${videoId}:`, error);
|
|
return baseResult;
|
|
}
|
|
})
|
|
);
|
|
|
|
// Filter out null results
|
|
const validResults = processedResults.filter((result): result is VideoResult => result !== null);
|
|
|
|
return {
|
|
results: validResults
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error("YouTube search error:", error);
|
|
throw error;
|
|
}
|
|
},
|
|
}),
|
|
retrieve: tool({
|
|
description: "Retrieve the information from a URL using Firecrawl.",
|
|
parameters: z.object({
|
|
url: z.string().describe("The URL to retrieve the information from."),
|
|
}),
|
|
execute: async ({ url }: { url: string }) => {
|
|
const app = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY });
|
|
try {
|
|
const content = await app.scrapeUrl(url);
|
|
if (!content.success || !content.metadata) {
|
|
return { error: "Failed to retrieve content" };
|
|
}
|
|
return {
|
|
results: [
|
|
{
|
|
title: content.metadata.title,
|
|
content: content.markdown,
|
|
url: content.metadata.sourceURL,
|
|
description: content.metadata.description,
|
|
language: content.metadata.language,
|
|
},
|
|
],
|
|
};
|
|
} catch (error) {
|
|
console.error("Firecrawl API error:", error);
|
|
return { error: "Failed to retrieve content" };
|
|
}
|
|
},
|
|
}),
|
|
get_weather_data: tool({
|
|
description: "Get the weather data for the given coordinates.",
|
|
parameters: z.object({
|
|
lat: z.number().describe("The latitude of the location."),
|
|
lon: z.number().describe("The longitude of the location."),
|
|
}),
|
|
execute: async ({ lat, lon }: { lat: number; lon: number }) => {
|
|
const apiKey = process.env.OPENWEATHER_API_KEY;
|
|
const response = await fetch(
|
|
`https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&appid=${apiKey}`,
|
|
);
|
|
const data = await response.json();
|
|
return data;
|
|
},
|
|
}),
|
|
programming: tool({
|
|
description: "Write and execute Python code.",
|
|
parameters: z.object({
|
|
title: z.string().describe("The title of the code snippet."),
|
|
code: z.string().describe("The Python code to execute. put the variables in the end of the code to print them. do not use the print function."),
|
|
icon: z.enum(["stock", "date", "calculation", "default"]).describe("The icon to display for the code snippet."),
|
|
}),
|
|
execute: async ({ code, title, icon }: { code: string, title: string, icon: string }) => {
|
|
console.log("Code:", code);
|
|
console.log("Title:", title);
|
|
console.log("Icon:", icon);
|
|
|
|
const sandbox = await CodeInterpreter.create(process.env.SANDBOX_TEMPLATE_ID!);
|
|
const execution = await sandbox.runCode(code);
|
|
let message = "";
|
|
let images = [];
|
|
|
|
if (execution.results.length > 0) {
|
|
for (const result of execution.results) {
|
|
if (result.isMainResult) {
|
|
message += `${result.text}\n`;
|
|
} else {
|
|
message += `${result.text}\n`;
|
|
}
|
|
if (result.formats().length > 0) {
|
|
const formats = result.formats();
|
|
for (let format of formats) {
|
|
if (format === "png" || format === "jpeg" || format === "svg") {
|
|
const imageData = result[format];
|
|
if (imageData && typeof imageData === 'string') {
|
|
const abortController = new AbortController();
|
|
try {
|
|
const blobPromise = put(`mplx/image-${Date.now()}.${format}`, Buffer.from(imageData, 'base64'),
|
|
{
|
|
access: 'public',
|
|
abortSignal: abortController.signal,
|
|
});
|
|
|
|
const timeout = setTimeout(() => {
|
|
// Abort the request after 2 seconds
|
|
abortController.abort();
|
|
}, 2000);
|
|
|
|
const blob = await blobPromise;
|
|
|
|
clearTimeout(timeout);
|
|
console.info('Blob put request completed', blob.url);
|
|
|
|
images.push({ format, url: blob.url });
|
|
} catch (error) {
|
|
if (error instanceof BlobRequestAbortedError) {
|
|
console.info('Canceled put request due to timeout');
|
|
} else {
|
|
console.error("Error saving image to Vercel Blob:", error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (execution.logs.stdout.length > 0 || execution.logs.stderr.length > 0) {
|
|
if (execution.logs.stdout.length > 0) {
|
|
message += `${execution.logs.stdout.join("\n")}\n`;
|
|
}
|
|
if (execution.logs.stderr.length > 0) {
|
|
message += `${execution.logs.stderr.join("\n")}\n`;
|
|
}
|
|
}
|
|
|
|
if (execution.error) {
|
|
message += `Error: ${execution.error}\n`;
|
|
console.log("Error: ", execution.error);
|
|
}
|
|
|
|
console.log(execution.results)
|
|
if (execution.results[0].chart) {
|
|
execution.results[0].chart.elements.map((element: any) => {
|
|
console.log(element.points)
|
|
})
|
|
}
|
|
|
|
return { message: message.trim(), images, chart: execution.results[0].chart ?? "" };
|
|
},
|
|
}),
|
|
find_place: tool({
|
|
description: "Find a place using Google Maps API for forward geocoding and Mapbox for reverse geocoding.",
|
|
parameters: z.object({
|
|
query: z.string().describe("The search query for forward geocoding"),
|
|
coordinates: z.array(z.number()).describe("Array of [latitude, longitude] for reverse geocoding"),
|
|
}),
|
|
execute: async ({ query, coordinates }: { query: string; coordinates: number[] }) => {
|
|
try {
|
|
// Forward geocoding with Google Maps API
|
|
const googleApiKey = process.env.GOOGLE_MAPS_API_KEY;
|
|
const googleResponse = await fetch(
|
|
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${googleApiKey}`
|
|
);
|
|
const googleData = await googleResponse.json();
|
|
|
|
// Reverse geocoding with Mapbox
|
|
const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN;
|
|
const [lat, lng] = coordinates;
|
|
const mapboxResponse = await fetch(
|
|
`https://api.mapbox.com/search/geocode/v6/reverse?longitude=${lng}&latitude=${lat}&access_token=${mapboxToken}`
|
|
);
|
|
const mapboxData = await mapboxResponse.json();
|
|
|
|
// Process and combine results
|
|
const features = [];
|
|
|
|
// Process Google results
|
|
if (googleData.status === 'OK' && googleData.results.length > 0) {
|
|
|
|
|
|
features.push(...googleData.results.map((result: GoogleResult) => ({
|
|
id: result.place_id,
|
|
name: result.formatted_address.split(',')[0],
|
|
formatted_address: result.formatted_address,
|
|
geometry: {
|
|
type: 'Point',
|
|
coordinates: [result.geometry.location.lng, result.geometry.location.lat]
|
|
},
|
|
feature_type: result.types[0],
|
|
address_components: result.address_components,
|
|
viewport: result.geometry.viewport,
|
|
place_id: result.place_id,
|
|
source: 'google'
|
|
})));
|
|
}
|
|
|
|
// Process Mapbox results
|
|
if (mapboxData.features && mapboxData.features.length > 0) {
|
|
|
|
features.push(...mapboxData.features.map((feature: any): MapboxFeature => ({
|
|
id: feature.id,
|
|
name: feature.properties.name_preferred || feature.properties.name,
|
|
formatted_address: feature.properties.full_address,
|
|
geometry: feature.geometry,
|
|
feature_type: feature.properties.feature_type,
|
|
context: feature.properties.context,
|
|
coordinates: feature.properties.coordinates,
|
|
bbox: feature.properties.bbox,
|
|
source: 'mapbox'
|
|
})));
|
|
}
|
|
|
|
return {
|
|
features,
|
|
google_attribution: "Powered by Google Maps Platform",
|
|
mapbox_attribution: "Powered by Mapbox"
|
|
};
|
|
} catch (error) {
|
|
console.error("Geocoding error:", error);
|
|
throw error;
|
|
}
|
|
},
|
|
}),
|
|
text_search: tool({
|
|
description: "Perform a text-based search for places using Mapbox API.",
|
|
parameters: z.object({
|
|
query: z.string().describe("The search query (e.g., '123 main street')."),
|
|
location: z.string().describe("The location to center the search (e.g., '42.3675294,-71.186966')."),
|
|
radius: z.number().describe("The radius of the search area in meters (max 50000)."),
|
|
}),
|
|
execute: async ({ query, location, radius }: {
|
|
query: string;
|
|
location?: string;
|
|
radius?: number;
|
|
}) => {
|
|
const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN;
|
|
|
|
let proximity = '';
|
|
if (location) {
|
|
const [lng, lat] = location.split(',').map(Number);
|
|
proximity = `&proximity=${lng},${lat}`;
|
|
}
|
|
|
|
const response = await fetch(
|
|
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?types=poi${proximity}&access_token=${mapboxToken}`
|
|
);
|
|
const data = await response.json();
|
|
|
|
// If location and radius provided, filter results by distance
|
|
let results = data.features;
|
|
if (location && radius) {
|
|
const [centerLng, centerLat] = location.split(',').map(Number);
|
|
const radiusInDegrees = radius / 111320;
|
|
results = results.filter((feature: any) => {
|
|
const [placeLng, placeLat] = feature.center;
|
|
const distance = Math.sqrt(
|
|
Math.pow(placeLng - centerLng, 2) + Math.pow(placeLat - centerLat, 2)
|
|
);
|
|
return distance <= radiusInDegrees;
|
|
});
|
|
}
|
|
|
|
return {
|
|
results: results.map((feature: any) => ({
|
|
name: feature.text,
|
|
formatted_address: feature.place_name,
|
|
geometry: {
|
|
location: {
|
|
lat: feature.center[1],
|
|
lng: feature.center[0]
|
|
}
|
|
}
|
|
}))
|
|
};
|
|
},
|
|
}),
|
|
text_translate: tool({
|
|
description: "Translate text from one language to another using Microsoft Translator.",
|
|
parameters: z.object({
|
|
text: z.string().describe("The text to translate."),
|
|
to: z.string().describe("The language to translate to (e.g., 'fr' for French)."),
|
|
from: z.string().describe("The source language (optional, will be auto-detected if not provided)."),
|
|
}),
|
|
execute: async ({ text, to, from }: { text: string; to: string; from?: string }) => {
|
|
const key = process.env.AZURE_TRANSLATOR_KEY;
|
|
const endpoint = "https://api.cognitive.microsofttranslator.com";
|
|
const location = process.env.AZURE_TRANSLATOR_LOCATION;
|
|
|
|
const url = `${endpoint}/translate?api-version=3.0&to=${to}${from ? `&from=${from}` : ''}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Ocp-Apim-Subscription-Key': key!,
|
|
'Ocp-Apim-Subscription-Region': location!,
|
|
'Content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify([{ text }]),
|
|
});
|
|
|
|
const data = await response.json();
|
|
return {
|
|
translatedText: data[0].translations[0].text,
|
|
detectedLanguage: data[0].detectedLanguage?.language,
|
|
};
|
|
},
|
|
}),
|
|
nearby_search: tool({
|
|
description: "Search for nearby places, such as restaurants or hotels based on the details given.",
|
|
parameters: z.object({
|
|
location: z.string().describe("The location name given by user."),
|
|
latitude: z.number().describe("The latitude of the location."),
|
|
longitude: z.number().describe("The longitude of the location."),
|
|
type: z.string().describe("The type of place to search for (restaurants, hotels, attractions, geos)."),
|
|
radius: z.number().default(6000).describe("The radius in meters (max 50000, default 6000)."),
|
|
}),
|
|
execute: async ({ location, latitude, longitude, type, radius }: {
|
|
latitude: number;
|
|
longitude: number;
|
|
location: string;
|
|
type: string;
|
|
radius: number;
|
|
}) => {
|
|
const apiKey = process.env.TRIPADVISOR_API_KEY;
|
|
let finalLat = latitude;
|
|
let finalLng = longitude;
|
|
|
|
try {
|
|
// Try geocoding first
|
|
const geocodingData = await fetch(
|
|
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(location)}&key=${process.env.GOOGLE_MAPS_API_KEY}`
|
|
);
|
|
|
|
const geocoding = await geocodingData.json();
|
|
|
|
if (geocoding.results?.[0]?.geometry?.location) {
|
|
let trimmedLat = geocoding.results[0].geometry.location.lat.toString().split('.');
|
|
finalLat = parseFloat(trimmedLat[0] + '.' + trimmedLat[1].slice(0, 6));
|
|
let trimmedLng = geocoding.results[0].geometry.location.lng.toString().split('.');
|
|
finalLng = parseFloat(trimmedLng[0] + '.' + trimmedLng[1].slice(0, 6));
|
|
console.log('Using geocoded coordinates:', finalLat, finalLng);
|
|
} else {
|
|
console.log('Using provided coordinates:', finalLat, finalLng);
|
|
}
|
|
|
|
// Get nearby places
|
|
const nearbyResponse = await fetch(
|
|
`https://api.content.tripadvisor.com/api/v1/location/nearby_search?latLong=${finalLat},${finalLng}&category=${type}&radius=${radius}&language=en&key=${apiKey}`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'origin': 'https://mplx.local',
|
|
'referer': 'https://mplx.local',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!nearbyResponse.ok) {
|
|
throw new Error(`Nearby search failed: ${nearbyResponse.status}`);
|
|
}
|
|
|
|
const nearbyData = await nearbyResponse.json();
|
|
|
|
if (!nearbyData.data || nearbyData.data.length === 0) {
|
|
console.log('No nearby places found');
|
|
return {
|
|
results: [],
|
|
center: { lat: finalLat, lng: finalLng }
|
|
};
|
|
}
|
|
|
|
// Process each place
|
|
const detailedPlaces = await Promise.all(
|
|
nearbyData.data.map(async (place: any) => {
|
|
try {
|
|
if (!place.location_id) {
|
|
console.log(`Skipping place "${place.name}": No location_id`);
|
|
return null;
|
|
}
|
|
|
|
// Fetch place details
|
|
const detailsResponse = await fetch(
|
|
`https://api.content.tripadvisor.com/api/v1/location/${place.location_id}/details?language=en¤cy=USD&key=${apiKey}`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'origin': 'https://mplx.local',
|
|
'referer': 'https://mplx.local',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!detailsResponse.ok) {
|
|
console.log(`Failed to fetch details for "${place.name}"`);
|
|
return null;
|
|
}
|
|
|
|
const details = await detailsResponse.json();
|
|
|
|
console.log(`Place details for "${place.name}":`, details);
|
|
|
|
// Fetch place photos
|
|
let photos = [];
|
|
try {
|
|
const photosResponse = await fetch(
|
|
`https://api.content.tripadvisor.com/api/v1/location/${place.location_id}/photos?language=en&key=${apiKey}`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'origin': 'https://mplx.local',
|
|
'referer': 'https://mplx.local',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (photosResponse.ok) {
|
|
const photosData = await photosResponse.json();
|
|
photos = photosData.data?.map((photo: any) => ({
|
|
thumbnail: photo.images?.thumbnail?.url,
|
|
small: photo.images?.small?.url,
|
|
medium: photo.images?.medium?.url,
|
|
large: photo.images?.large?.url,
|
|
original: photo.images?.original?.url,
|
|
caption: photo.caption
|
|
})).filter((photo: any) => photo.medium) || [];
|
|
}
|
|
} catch (error) {
|
|
console.log(`Photo fetch failed for "${place.name}":`, error);
|
|
}
|
|
|
|
|
|
|
|
// Get timezone for the location
|
|
const tzResponse = await fetch(
|
|
`https://maps.googleapis.com/maps/api/timezone/json?location=${details.latitude},${details.longitude}×tamp=${Math.floor(Date.now() / 1000)}&key=${process.env.GOOGLE_MAPS_API_KEY}`
|
|
);
|
|
const tzData = await tzResponse.json();
|
|
const timezone = tzData.timeZoneId || 'UTC';
|
|
|
|
// Process hours and status with timezone
|
|
const localTime = new Date(new Date().toLocaleString('en-US', { timeZone: timezone }));
|
|
const currentDay = localTime.getDay();
|
|
const currentHour = localTime.getHours();
|
|
const currentMinute = localTime.getMinutes();
|
|
const currentTime = currentHour * 100 + currentMinute;
|
|
|
|
let is_closed = true;
|
|
let next_open_close = null;
|
|
let next_day = currentDay;
|
|
|
|
if (details.hours?.periods) {
|
|
// Sort periods by day and time for proper handling of overnight hours
|
|
const sortedPeriods = [...details.hours.periods].sort((a, b) => {
|
|
if (a.open.day !== b.open.day) return a.open.day - b.open.day;
|
|
return parseInt(a.open.time) - parseInt(b.open.time);
|
|
});
|
|
|
|
// Find current or next opening period
|
|
for (let i = 0; i < sortedPeriods.length; i++) {
|
|
const period = sortedPeriods[i];
|
|
const openTime = parseInt(period.open.time);
|
|
const closeTime = period.close ? parseInt(period.close.time) : 2359;
|
|
const periodDay = period.open.day;
|
|
|
|
// Handle overnight hours
|
|
if (closeTime < openTime) {
|
|
// Place is open from previous day
|
|
if (currentDay === periodDay && currentTime < closeTime) {
|
|
is_closed = false;
|
|
next_open_close = period.close.time;
|
|
break;
|
|
}
|
|
// Place is open today and extends to tomorrow
|
|
if (currentDay === periodDay && currentTime >= openTime) {
|
|
is_closed = false;
|
|
next_open_close = period.close.time;
|
|
next_day = (periodDay + 1) % 7;
|
|
break;
|
|
}
|
|
} else {
|
|
// Normal hours within same day
|
|
if (currentDay === periodDay && currentTime >= openTime && currentTime < closeTime) {
|
|
is_closed = false;
|
|
next_open_close = period.close.time;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Find next opening time if currently closed
|
|
if (is_closed) {
|
|
if ((periodDay > currentDay) || (periodDay === currentDay && openTime > currentTime)) {
|
|
next_open_close = period.open.time;
|
|
next_day = periodDay;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return processed place data
|
|
return {
|
|
name: place.name || 'Unnamed Place',
|
|
location: {
|
|
lat: parseFloat(details.latitude || place.latitude || finalLat),
|
|
lng: parseFloat(details.longitude || place.longitude || finalLng)
|
|
},
|
|
timezone,
|
|
place_id: place.location_id,
|
|
vicinity: place.address_obj?.address_string || '',
|
|
distance: parseFloat(place.distance || '0'),
|
|
bearing: place.bearing || '',
|
|
type: type,
|
|
rating: parseFloat(details.rating || '0'),
|
|
price_level: details.price_level || '',
|
|
cuisine: details.cuisine?.[0]?.name || '',
|
|
description: details.description || '',
|
|
phone: details.phone || '',
|
|
website: details.website || '',
|
|
reviews_count: parseInt(details.num_reviews || '0'),
|
|
is_closed,
|
|
hours: details.hours?.weekday_text || [],
|
|
next_open_close,
|
|
next_day,
|
|
periods: details.hours?.periods || [],
|
|
photos,
|
|
source: details.source?.name || 'TripAdvisor'
|
|
};
|
|
} catch (error) {
|
|
console.log(`Failed to process place "${place.name}":`, error);
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
|
|
// Filter and sort results
|
|
const validPlaces = detailedPlaces
|
|
.filter(place => place !== null)
|
|
.sort((a, b) => (a?.distance || 0) - (b?.distance || 0));
|
|
|
|
return {
|
|
results: validPlaces,
|
|
center: { lat: finalLat, lng: finalLng }
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Nearby search error:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
}),
|
|
track_flight: tool({
|
|
description: "Track flight information and status",
|
|
parameters: z.object({
|
|
flight_number: z.string().describe("The flight number to track"),
|
|
}),
|
|
execute: async ({ flight_number }: { flight_number: string }) => {
|
|
try {
|
|
const response = await fetch(
|
|
`https://api.aviationstack.com/v1/flights?access_key=${process.env.AVIATION_STACK_API_KEY}&flight_iata=${flight_number}`
|
|
);
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Flight tracking error:', error);
|
|
throw error;
|
|
}
|
|
},
|
|
}),
|
|
},
|
|
onChunk(event) {
|
|
if (event.chunk.type === "tool-call") {
|
|
console.log("Called Tool: ", event.chunk.toolName);
|
|
}
|
|
},
|
|
onStepFinish(event) {
|
|
if (event.warnings) {
|
|
console.log("Warnings: ", event.warnings);
|
|
}
|
|
},
|
|
onFinish(event) {
|
|
console.log("Fin reason: ", event.finishReason);
|
|
console.log("Steps ", event.steps);
|
|
console.log("Messages: ", event.response.messages[event.response.messages.length - 1].content);
|
|
},
|
|
});
|
|
|
|
return result.toDataStreamResponse();
|
|
}
|