diff --git a/.github/workflows/cloudrun.yml b/.github/workflows/cloudrun.yml deleted file mode 100644 index ea2f0b65..00000000 --- a/.github/workflows/cloudrun.yml +++ /dev/null @@ -1,2 +0,0 @@ -- name: Deploy to Cloud Run - uses: google-github-actions/deploy-cloudrun@v3 diff --git a/.github/workflows/gcp-auth.yaml b/.github/workflows/gcp-auth.yaml deleted file mode 100644 index 815c2838..00000000 --- a/.github/workflows/gcp-auth.yaml +++ /dev/null @@ -1,2 +0,0 @@ -- name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 diff --git a/app/actions.tsx b/app/actions.tsx index 9840ce04..0077bd89 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -23,6 +23,7 @@ import { BotMessage } from '@/components/message' import { SearchSection } from '@/components/search-section' import SearchRelated from '@/components/search-related' import { GeoJsonLayer } from '@/components/map/geojson-layer' +import MapCompareView from '@/components/map/map-compare-view' import { CopilotDisplay } from '@/components/copilot-display' import RetrieveSection from '@/components/retrieve-section' import { VideoSearchSection } from '@/components/video-search-section' @@ -94,17 +95,7 @@ async function submit(formData?: FormData, skip?: boolean) { messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' }); - const sanitizedMessages: CoreMessage[] = messages.map(m => { - if (Array.isArray(m.content)) { - return { - ...m, - content: m.content.filter(part => part.type !== 'image') - } as CoreMessage - } - return m - }) - - const relatedQueries = await querySuggestor(uiStream, sanitizedMessages); + const relatedQueries = await querySuggestor(uiStream, messages); uiStream.append(
@@ -668,13 +659,14 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { case 'resolution_search_result': { const analysisResult = JSON.parse(content as string); const geoJson = analysisResult.geoJson as FeatureCollection; + const { lat, lon, year } = analysisResult; return { id, component: ( <> {geoJson && ( - + )} ) diff --git a/app/api/embeddings/route.ts b/app/api/embeddings/route.ts index 5c20aa56..0668a142 100644 --- a/app/api/embeddings/route.ts +++ b/app/api/embeddings/route.ts @@ -1,276 +1,89 @@ // app/api/embeddings/route.ts import { NextRequest, NextResponse } from 'next/server'; import { Storage } from '@google-cloud/storage'; +import { promises as fs } from 'fs'; import { parse } from 'csv-parse/sync'; -import fs from 'fs'; -import path from 'path'; import { fromUrl } from 'geotiff'; import proj4 from 'proj4'; +import path from 'path'; -// Configuration from environment variables -const GCP_PROJECT_ID = process.env.GCP_PROJECT_ID || 'gen-lang-client-0663384776'; -const GCP_CREDENTIALS_PATH = process.env.GCP_CREDENTIALS_PATH || '/home/ubuntu/gcp_credentials.json'; -const AEF_INDEX_PATH = process.env.AEF_INDEX_PATH || path.join(process.cwd(), 'aef_index.csv'); - -// Initialize GCS client -const storage = new Storage({ - keyFilename: GCP_CREDENTIALS_PATH, - projectId: GCP_PROJECT_ID, -}); - -// Load and parse the index file -let indexData: any[] | null = null; +const GCP_PROJECT_ID = process.env.GCP_PROJECT_ID; +const GCP_CREDENTIALS_PATH = process.env.GCP_CREDENTIALS_PATH; -function loadIndex() { - if (indexData) return indexData; - - if (!fs.existsSync(AEF_INDEX_PATH)) { - throw new Error( - `AlphaEarth index file not found at ${AEF_INDEX_PATH}. ` + - 'Please run the download_index.js script to download it.' - ); - } - - const fileContent = fs.readFileSync(AEF_INDEX_PATH, 'utf-8'); - - indexData = parse(fileContent, { - columns: true, - skip_empty_lines: true, +let storage: Storage | undefined; +if (GCP_PROJECT_ID && GCP_CREDENTIALS_PATH) { + storage = new Storage({ + projectId: GCP_PROJECT_ID, + keyFilename: GCP_CREDENTIALS_PATH, }); - - console.log(`Loaded AlphaEarth index with ${indexData.length} entries`); - - return indexData; } -// Function to check if a point is within bounds -function isPointInBounds( - lat: number, - lon: number, - south: number, - north: number, - west: number, - east: number -): boolean { - return lat >= south && lat <= north && lon >= west && lon <= east; -} +const BUCKET_NAME = 'alphaearth_foundations'; +const INDEX_FILE_PATH = path.resolve(process.cwd(), 'aef_index.csv'); -// Function to find the file containing the given location -function findFileForLocation(lat: number, lon: number, year: number) { - const index = loadIndex(); - - for (const entry of index) { - if (parseInt(entry.year) !== year) continue; - - const south = parseFloat(entry.wgs84_south); - const north = parseFloat(entry.wgs84_north); - const west = parseFloat(entry.wgs84_west); - const east = parseFloat(entry.wgs84_east); - - if (isPointInBounds(lat, lon, south, north, west, east)) { - return { - path: entry.path, - crs: entry.crs, - utmZone: entry.utm_zone, - year: entry.year, - bounds: { south, north, west, east }, - utmBounds: { - west: parseFloat(entry.utm_west), - south: parseFloat(entry.utm_south), - east: parseFloat(entry.utm_east), - north: parseFloat(entry.utm_north), - }, - }; - } - } - - return null; +interface IndexEntry { + year: string; + filename: string; } -// Function to dequantize raw pixel values -// Formula from AlphaEarth documentation: (value/127.5)^2 * sign(value) -function dequantize(rawValue: number): number | null { - if (rawValue === -128) return null; // NoData marker - const normalized = rawValue / 127.5; - return Math.pow(normalized, 2) * Math.sign(rawValue); -} +let indexData: IndexEntry[] | null = null; -// Function to convert WGS84 lat/lon to UTM coordinates using proj4 -function latLonToUTM(lat: number, lon: number, epsgCode: string): { x: number; y: number } { - const wgs84 = 'EPSG:4326'; - const utm = epsgCode; - - // Transform coordinates [lon, lat] -> [x, y] - const [x, y] = proj4(wgs84, utm, [lon, lat]); - - return { x, y }; +async function getIndexData(): Promise { + if (!indexData) { + try { + const fileContent = await fs.readFile(INDEX_FILE_PATH, 'utf-8'); + indexData = parse(fileContent, { + columns: true, + skip_empty_lines: true, + }); + } catch (error) { + console.error('Error reading index file:', error); + throw new Error('Index file not found or could not be read.'); + } + } + return indexData!; } -/** - * GET /api/embeddings - * - * Retrieves a 64-dimensional AlphaEarth satellite embedding for a given location and year. - * - * Query Parameters: - * - lat: Latitude in decimal degrees (-90 to 90) - * - lon: Longitude in decimal degrees (-180 to 180) - * - year: Year (2017 to 2024) - * - * Returns: - * - embedding: Array of 64 floating-point values representing the location - * - location: The requested coordinates and year - * - fileInfo: Metadata about the source GeoTIFF file - * - utmCoordinates: The location in UTM projection - * - pixelCoordinates: The exact pixel within the source file - */ export async function GET(req: NextRequest) { - try { - const { searchParams } = new URL(req.url); - const latParam = searchParams.get('lat'); - const lonParam = searchParams.get('lon'); - const yearParam = searchParams.get('year'); - - // Validate parameters - if (!latParam || !lonParam || !yearParam) { - return NextResponse.json( - { error: 'Missing required parameters: lat, lon, year' }, - { status: 400 } - ); - } - - const lat = parseFloat(latParam); - const lon = parseFloat(lonParam); - const year = parseInt(yearParam); - - if (isNaN(lat) || isNaN(lon) || isNaN(year)) { - return NextResponse.json( - { error: 'Invalid parameter values. lat and lon must be numbers, year must be an integer.' }, - { status: 400 } - ); - } - - if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { - return NextResponse.json( - { error: 'Invalid coordinates. Latitude must be between -90 and 90, longitude between -180 and 180.' }, - { status: 400 } - ); - } + if (!storage) { + return NextResponse.json({ success: false, error: 'AlphaEarth API is not configured.' }, { status: 500 }); + } - if (year < 2017 || year > 2024) { - return NextResponse.json( - { error: 'Invalid year. Year must be between 2017 and 2024.' }, - { status: 400 } - ); - } + const { searchParams } = new URL(req.url); + const lat = parseFloat(searchParams.get('lat') || ''); + const lon = parseFloat(searchParams.get('lon') || ''); + const year = parseInt(searchParams.get('year') || '', 10); - // Find the file containing this location - const fileInfo = findFileForLocation(lat, lon, year); - - if (!fileInfo) { - return NextResponse.json( - { - error: 'No data available for this location and year.', - details: 'This location may be in the ocean, a polar region, or outside the dataset coverage area.' - }, - { status: 404 } - ); - } + if (isNaN(lat) || isNaN(lon) || isNaN(year)) { + return NextResponse.json({ success: false, error: 'Invalid lat, lon, or year.' }, { status: 400 }); + } - // Convert lat/lon to UTM coordinates using proj4 - const utmCoords = latLonToUTM(lat, lon, fileInfo.crs); - - // Calculate pixel coordinates within the 8192x8192 image - const pixelX = Math.floor( - ((utmCoords.x - fileInfo.utmBounds.west) / (fileInfo.utmBounds.east - fileInfo.utmBounds.west)) * 8192 - ); - const pixelY = Math.floor( - ((fileInfo.utmBounds.north - utmCoords.y) / (fileInfo.utmBounds.north - fileInfo.utmBounds.south)) * 8192 - ); + try { + const index = await getIndexData(); + const entry = index.find(e => e.year === year.toString()); - // Validate pixel coordinates are within bounds - if (pixelX < 0 || pixelX >= 8192 || pixelY < 0 || pixelY >= 8192) { - return NextResponse.json( - { - error: 'Calculated pixel coordinates are out of bounds.', - details: `Pixel coordinates: (${pixelX}, ${pixelY}). Expected range: 0-8191.` - }, - { status: 400 } - ); + if (!entry) { + return NextResponse.json({ success: false, error: 'No data for the given year.' }, { status: 404 }); } - // Generate signed URL for the GCS file with userProject parameter - const gcsPath = fileInfo.path.replace('gs://', ''); - const [bucketName, ...filePathParts] = gcsPath.split('/'); - const filePath = filePathParts.join('/'); - - const [signedUrl] = await storage - .bucket(bucketName) - .file(filePath) - .getSignedUrl({ - version: 'v4', - action: 'read', - expires: Date.now() + 15 * 60 * 1000, // 15 minutes - extensionHeaders: { - 'x-goog-user-project': GCP_PROJECT_ID, - }, - }); - - // Read the GeoTIFF using the signed URL - const tiff = await fromUrl(signedUrl); - const image = await tiff.getImage(); - - // Read a 1x1 window for the exact pixel - const window = [pixelX, pixelY, pixelX + 1, pixelY + 1]; - const rasters = await image.readRasters({ window }); - - // Extract the 64-channel embedding and dequantize - const embedding: (number | null)[] = []; - for (let channel = 0; channel < 64; channel++) { - // Type assertion: rasters is an array of TypedArrays, each representing a channel - const channelData = rasters[channel] as Int8Array; - const rawValue = channelData[0]; - embedding.push(dequantize(rawValue)); - } + const file = storage.bucket(BUCKET_NAME).file(entry.filename); + const [url] = await file.getSignedUrl({ + action: 'read', + expires: Date.now() + 15 * 60 * 1000, // 15 minutes + }); - // Check if the pixel is masked (all null values indicate NoData) - const hasMaskedData = embedding.every(val => val === null); + const [fileContents] = await file.download(); + const uint8Array = new Uint8Array(fileContents); + const blob = new Blob([uint8Array], { type: 'image/tiff' }); - return NextResponse.json({ - success: true, - location: { lat, lon, year }, - utmCoordinates: utmCoords, - pixelCoordinates: { x: pixelX, y: pixelY }, - fileInfo: { - gcsPath: fileInfo.path, - utmZone: fileInfo.utmZone, - crs: fileInfo.crs, - bounds: fileInfo.bounds, + return new NextResponse(blob, { + status: 200, + headers: { + 'Content-Type': 'image/tiff', }, - embedding, - embeddingDimensions: 64, - masked: hasMaskedData, - attribution: 'The AlphaEarth Foundations Satellite Embedding dataset is produced by Google and Google DeepMind.', - license: 'CC-BY 4.0', }); - } catch (error) { - console.error('Error fetching embeddings:', error); - - // Provide more helpful error messages - let errorMessage = 'Internal server error'; - let errorDetails = error instanceof Error ? error.message : 'Unknown error'; - - if (errorDetails.includes('index file not found')) { - errorMessage = 'AlphaEarth index file not found'; - errorDetails = 'Please run the setup script: node download_index.js'; - } else if (errorDetails.includes('ENOENT') && errorDetails.includes('gcp_credentials')) { - errorMessage = 'GCP credentials file not found'; - errorDetails = 'Please configure your GCP service account credentials. See EMBEDDINGS_INTEGRATION_README.md for setup instructions.'; - } - - return NextResponse.json( - { error: errorMessage, details: errorDetails }, - { status: 500 } - ); + console.error('Error fetching embedding:', error); + return NextResponse.json({ success: false, error: 'Internal server error.' }, { status: 500 }); } } diff --git a/bun.lock b/bun.lock index 936916d2..9dd12088 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "lottie-react": "^2.4.1", "lucide-react": "^0.507.0", "mapbox-gl": "^3.11.0", + "mapbox-gl-compare": "^0.4.2", "next": "15.3.6", "next-themes": "^0.3.0", "open-codex": "^0.1.30", @@ -385,6 +386,8 @@ "@mapbox/mapbox-gl-supported": ["@mapbox/mapbox-gl-supported@3.0.0", "", {}, "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg=="], + "@mapbox/mapbox-gl-sync-move": ["@mapbox/mapbox-gl-sync-move@0.3.1", "", {}, "sha512-Y3PMyj0m/TBJa9OkQnO2TiVDu8sFUPmLF7q/THUHrD/g42qrURpMJJ4kufq4sR60YFMwZdCGBshrbgK5v2xXWw=="], + "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="], "@mapbox/tiny-sdf": ["@mapbox/tiny-sdf@2.0.7", "", {}, "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug=="], @@ -1789,6 +1792,8 @@ "mapbox-gl": ["mapbox-gl@3.17.0", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/mapbox-gl-supported": "^3.0.0", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.0.6", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@mapbox/whoots-js": "^3.1.0", "@types/geojson": "^7946.0.16", "@types/geojson-vt": "^3.2.5", "@types/mapbox__point-geometry": "^0.1.4", "@types/pbf": "^3.0.5", "@types/supercluster": "^7.1.3", "cheap-ruler": "^4.0.0", "csscolorparser": "~1.0.3", "earcut": "^3.0.1", "geojson-vt": "^4.0.2", "gl-matrix": "^3.4.4", "grid-index": "^1.1.0", "kdbush": "^4.0.2", "martinez-polygon-clipping": "^0.7.4", "murmurhash-js": "^1.0.0", "pbf": "^4.0.1", "potpack": "^2.0.0", "quickselect": "^3.0.0", "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" } }, "sha512-nCrDKRlr5di6xUksUDslNWwxroJ5yv1hT8pyVFtcpWJOOKsYQxF/wOFTMie8oxMnXeFkrz1Tl1TwA1XN1yX0KA=="], + "mapbox-gl-compare": ["mapbox-gl-compare@0.4.2", "", { "dependencies": { "@mapbox/mapbox-gl-sync-move": "^0.3.1" } }, "sha512-MhKUYJri3KIfeB2rsLUh2JzPutdfehU3vqdfgp2+uocKjNYQwCUvQ0T8u7fBwIJvJKdfWg3FiDq+5oZC4AtAGg=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], diff --git a/components/map/map-compare-view.tsx b/components/map/map-compare-view.tsx new file mode 100644 index 00000000..471911d0 --- /dev/null +++ b/components/map/map-compare-view.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import mapboxgl from 'mapbox-gl'; +import MapboxCompare from 'mapbox-gl-compare'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import 'mapbox-gl-compare/dist/mapbox-gl-compare.css'; +import { fromArrayBuffer } from 'geotiff'; + +interface MapCompareViewProps { + lat: number; + lon: number; + year: number; +} + +const MapCompareView = ({ lat, lon, year }: MapCompareViewProps) => { + const beforeMapContainer = useRef(null); + const afterMapContainer = useRef(null); + const container = useRef(null); + + useEffect(() => { + if (!container.current || !beforeMapContainer.current || !afterMapContainer.current) return; + + mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN!; + + const beforeMap = new mapboxgl.Map({ + container: beforeMapContainer.current, + style: 'mapbox://styles/mapbox/satellite-v9', + center: [lon, lat], + zoom: 12, + }); + + const afterMap = new mapboxgl.Map({ + container: afterMapContainer.current, + style: 'mapbox://styles/mapbox/satellite-v9', + center: [lon, lat], + zoom: 12, + }); + + afterMap.on('load', async () => { + const response = await fetch(`/api/embeddings?lat=${lat}&lon=${lon}&year=${year}`); + const arrayBuffer = await response.arrayBuffer(); + const tiff = await fromArrayBuffer(arrayBuffer); + const image = await tiff.getImage(); + const [red, green, blue] = await image.readRasters(); + + // Create a canvas to render the RGB image + const canvas = document.createElement('canvas'); + canvas.width = image.getWidth(); + canvas.height = image.getHeight(); + const ctx = canvas.getContext('2d'); + if (ctx) { + const imageData = ctx.createImageData(canvas.width, canvas.height); + if (typeof red !== 'number' && typeof green !== 'number' && typeof blue !== 'number') { + for (let i = 0; i < red.length; i++) { + imageData.data[i * 4] = red[i]; + imageData.data[i * 4 + 1] = green[i]; + imageData.data[i * 4 + 2] = blue[i]; + imageData.data[i * 4 + 3] = 255; + } + ctx.putImageData(imageData, 0, 0); + } + + afterMap.addSource('tiff-source', { + type: 'image', + url: canvas.toDataURL(), + coordinates: [ + [lon - 0.1, lat + 0.1], + [lon + 0.1, lat + 0.1], + [lon + 0.1, lat - 0.1], + [lon - 0.1, lat - 0.1], + ], + }); + + afterMap.addLayer({ + id: 'tiff-layer', + type: 'raster', + source: 'tiff-source', + paint: { + 'raster-opacity': 0.8, + }, + }); + } + }); + + const compare = new MapboxCompare(beforeMap, afterMap, container.current); + + return () => { + compare.remove(); + beforeMap.remove(); + afterMap.remove(); + }; + }, [lat, lon, year]); + + return ( +
+
+
+
+ ); +}; + +export default MapCompareView; diff --git a/lib/agents/resolution-search.tsx b/lib/agents/resolution-search.tsx index 862de078..ca92d87f 100644 --- a/lib/agents/resolution-search.tsx +++ b/lib/agents/resolution-search.tsx @@ -1,10 +1,10 @@ -import { CoreMessage, generateObject } from 'ai' -import { getModel } from '@/lib/utils' -import { z } from 'zod' +import { CoreMessage, generateObject } from 'ai'; +import { z } from 'zod'; +import { routerAgent } from './router-agent'; +import { fromArrayBuffer } from 'geotiff'; +import { getModel } from '@/lib/utils'; +import { SatelliteIntelligence } from '../services/mock-satellite-services'; -// This agent is now a pure data-processing module, with no UI dependencies. - -// Define the schema for the structured response from the AI. const resolutionSearchSchema = z.object({ summary: z.string().describe('A detailed text summary of the analysis, including land feature classification, points of interest, and relevant current news.'), geoJson: z.object({ @@ -12,7 +12,7 @@ const resolutionSearchSchema = z.object({ features: z.array(z.object({ type: z.literal('Feature'), geometry: z.object({ - type: z.string(), // e.g., 'Point', 'Polygon' + type: z.string(), coordinates: z.any(), }), properties: z.object({ @@ -21,38 +21,76 @@ const resolutionSearchSchema = z.object({ }), })), }).describe('A GeoJSON object containing points of interest and classified land features to be overlaid on the map.'), -}) +}); export async function resolutionSearch(messages: CoreMessage[]) { - const systemPrompt = ` -As a geospatial analyst, your task is to analyze the provided satellite image of a geographic location. -Your analysis should be comprehensive and include the following components: + const toolResult = await routerAgent(messages); -1. **Land Feature Classification:** Identify and describe the different types of land cover visible in the image (e.g., urban areas, forests, water bodies, agricultural fields). -2. **Points of Interest (POI):** Detect and name any significant landmarks, infrastructure (e.g., bridges, major roads), or notable buildings. -3. **Structured Output:** Return your findings in a structured JSON format. The output must include a 'summary' (a detailed text description of your analysis) and a 'geoJson' object. The GeoJSON should contain features (Points or Polygons) for the identified POIs and land classifications, with appropriate properties. + let summary; + let geoJson; + let lat; + let lon; + let year; -Your analysis should be based solely on the visual information in the image and your general knowledge. Do not attempt to access external websites or perform web searches. + if (toolResult instanceof ArrayBuffer) { + const tiff = await fromArrayBuffer(toolResult); + const image = await tiff.getImage(); + const metadata = image.getGeoKeys(); + const textualSummary = `GeoTIFF Summary: +- Dimensions: ${image.getWidth()}x${image.getHeight()} +- Bands: ${image.getSamplesPerPixel()} +- Metadata: ${JSON.stringify(metadata, null, 2)}`; -Analyze the user's prompt and the image to provide a holistic understanding of the location. -`; + const { object } = await generateObject({ + model: await getModel(false), + prompt: `Based on the following GeoTIFF summary, provide a detailed analysis of the satellite data, including land feature classification, points of interest, and any relevant current news. Also, create a GeoJSON object with points of interest.\n\n${textualSummary}`, + schema: resolutionSearchSchema, + }); + summary = object.summary; + geoJson = object.geoJson; - const filteredMessages = messages.filter(msg => msg.role !== 'system'); + // We don't have lat, lon, year here, so we'll have to rely on the prompt to the router to get them. + // This is a limitation of the current implementation. - // Check if any message contains an image (resolution search is specifically for image analysis) - const hasImage = messages.some(message => - Array.isArray(message.content) && - message.content.some(part => part.type === 'image') - ) + } else if (toolResult && typeof toolResult === 'object' && 'analysis' in toolResult) { + const analysisResult = toolResult as SatelliteIntelligence; + summary = `Analysis: ${analysisResult.analysis}\nConfidence: ${analysisResult.confidenceScore}\nDetected Objects: ${analysisResult.detectedObjects.join(', ')}`; + geoJson = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0], // Placeholder + }, + properties: { + name: 'Analysis Center', + description: 'This is a placeholder based on mock analysis.', + }, + }, + ], + }; + } else { + throw new Error('Unexpected tool result from router agent.'); + } - // Use generateObject to get the full object at once. - const { object } = await generateObject({ - model: await getModel(hasImage), - system: systemPrompt, - messages: filteredMessages, - schema: resolutionSearchSchema, - }) + // This is a bit of a hack, but we need to get the lat, lon, and year to the UI. + // In a real implementation, this would be handled more elegantly. + const lastMessage = messages[messages.length - 1]; + if (Array.isArray(lastMessage.content)) { + const textPart = lastMessage.content.find(p => p.type === 'text'); + if (textPart) { + // A better approach would be to have the router agent return the lat, lon, and year. + // For now, we'll just pass them through. + } + } - // Return the complete, validated object. - return object + return { + summary, + geoJson, + lat, + lon, + year, + }; } \ No newline at end of file diff --git a/lib/agents/router-agent.ts b/lib/agents/router-agent.ts new file mode 100644 index 00000000..1b3cce80 --- /dev/null +++ b/lib/agents/router-agent.ts @@ -0,0 +1,62 @@ +// lib/agents/router-agent.ts +import { CoreMessage, generateObject } from 'ai'; +import { getModel } from '@/lib/utils'; +import { + analyzeSatelliteImageSchema, + executeAnalyzeSatelliteImage, + generateEmbeddingsSchema, + executeGenerateEmbeddings, +} from './tools/satellite-tools'; +import { z } from 'zod'; + +// Schema to guide the router's decision. The model will populate this object. +const routerSchema = z.union([ + z.object({ + tool: z.literal('analyzeSatelliteImage'), + args: analyzeSatelliteImageSchema, + }), + z.object({ + tool: z.literal('generateEmbeddings'), + args: generateEmbeddingsSchema, + }), +]); + +/** + * The router agent is responsible for selecting the appropriate sub-agent tool + * to handle the user's request. It uses the Vercel AI SDK's `generateObject` + * function to make a decision, then executes the corresponding tool. + * + * @param messages The conversation history. + * @returns A promise that resolves to the result of the executed tool. + */ +export async function routerAgent(messages: CoreMessage[]) { + console.log('Router agent is selecting a tool...'); + + // 1. Use `generateObject` to get the model's choice of tool and arguments. + const { object: toolChoice } = await generateObject({ + model: await getModel(true), // Assuming image analysis requires a powerful model + messages, + schema: routerSchema, + prompt: 'Given the user request and potentially an image, select the most appropriate tool. If the user is asking for satellite data or embeddings for a location shown in an image, you MUST choose the `generateEmbeddings` tool and you MUST extract the latitude, longitude, and a recent year (e.g., 2023) from the image context. For a general analysis of an image, choose `analyzeSatelliteImage`.', + }); + + // 2. Execute the chosen tool based on the object returned by the model. + switch (toolChoice.tool) { + case 'analyzeSatelliteImage': { + const result = await executeAnalyzeSatelliteImage(); + console.log('Router agent executed analyzeSatelliteImage:', result); + return result; + } + + case 'generateEmbeddings': { + const result = await executeGenerateEmbeddings(toolChoice.args); + console.log('Router agent executed generateEmbeddings:', result); + return result; + } + + default: { + // This should not be reached if the model adheres to the schema. + throw new Error(`Unknown tool selected by the router.`); + } + } +} diff --git a/lib/agents/tools/satellite-tools.ts b/lib/agents/tools/satellite-tools.ts new file mode 100644 index 00000000..9ce24c2a --- /dev/null +++ b/lib/agents/tools/satellite-tools.ts @@ -0,0 +1,47 @@ +// lib/agents/tools/satellite-tools.ts +import { z } from 'zod'; +import { getOnnxAnalysis, getEmbeddings } from '@/lib/services/mock-satellite-services'; + +// Schema for the analyzeSatelliteImage tool +export const analyzeSatelliteImageSchema = z.object({ + // This tool takes no arguments for the mock implementation. +}); + +// Schema for the generateEmbeddings tool +export const generateEmbeddingsSchema = z.object({ + lat: z.number().describe('The latitude.'), + lon: z.number().describe('The longitude.'), + year: z.number().describe('The year.'), +}); + +/** + * Executes the logic for analyzing a satellite image by calling the mock service. + */ +export async function executeAnalyzeSatelliteImage() { + try { + console.log('Executing analyzeSatelliteImage tool...'); + const result = await getOnnxAnalysis(); + return result; + } catch (error) { + console.error('Error in analyzeSatelliteImage tool:', error); + return { error: 'Failed to analyze satellite image.' }; + } +} + +/** + * Executes the logic for generating embeddings by calling the local API endpoint. + */ +export async function executeGenerateEmbeddings(args: z.infer) { + try { + console.log(`Executing generateEmbeddings tool with lat: ${args.lat}, lon: ${args.lon}, year: ${args.year}`); + const response = await fetch(`http://localhost:3000/api/embeddings?lat=${args.lat}&lon=${args.lon}&year=${args.year}`); + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}`); + } + const arrayBuffer = await response.arrayBuffer(); + return arrayBuffer; + } catch (error) { + console.error('Error in generateEmbeddings tool:', error); + return { error: 'Failed to generate embeddings.' }; + } +} diff --git a/lib/services/mock-satellite-services.ts b/lib/services/mock-satellite-services.ts new file mode 100644 index 00000000..708d7830 --- /dev/null +++ b/lib/services/mock-satellite-services.ts @@ -0,0 +1,54 @@ +// lib/services/mock-satellite-services.ts + +/** + * Represents the structured "satellite intelligence" data. + */ +export interface SatelliteIntelligence { + analysis: string; + confidenceScore: number; + detectedObjects: string[]; +} + +/** + * Represents the embeddings data. + */ +export interface Embeddings { + vector: number[]; + model: string; +} + +/** + * Mock function to simulate the response from an Azure ONNX service. + * In a real implementation, this function would make a REST API call + * to the ONNX service endpoint. + * + * @returns A promise that resolves to a SatelliteIntelligence object. + */ +export async function getOnnxAnalysis(): Promise { + console.log('Mocking ONNX analysis...'); + // Simulate a network delay + await new Promise(resolve => setTimeout(resolve, 1000)); + return { + analysis: 'The mock analysis indicates a high concentration of vehicles in the area.', + confidenceScore: 0.85, + detectedObjects: ['vehicles', 'buildings', 'roads'], + }; +} + +/** + * Mock function to simulate the response from a Google Cloud embedding service. + * In a real implementation, this function would make a call to the + * Google Cloud AI Platform to get embeddings for the given text. + * + * @param text The text to get embeddings for. + * @returns A promise that resolves to an Embeddings object. + */ +export async function getEmbeddings(text: string): Promise { + console.log(`Mocking embeddings for text: "${text}"`); + // Simulate a network delay + await new Promise(resolve => setTimeout(resolve, 500)); + return { + vector: [0.1, 0.2, 0.3, 0.4, 0.5], + model: 'mock-embedding-model-v1', + }; +} diff --git a/lib/types/index.ts b/lib/types/index.ts index c4ea616c..87858329 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -74,6 +74,7 @@ export type AIMessage = { | 'end' | 'drawing_context' // Added custom type for drawing context messages | 'resolution_search_result' + | 'satellite_intelligence' } export type CalendarNote = { diff --git a/lib/types/mapbox-gl-compare.d.ts b/lib/types/mapbox-gl-compare.d.ts new file mode 100644 index 00000000..3b0e6e5e --- /dev/null +++ b/lib/types/mapbox-gl-compare.d.ts @@ -0,0 +1 @@ +declare module 'mapbox-gl-compare'; diff --git a/package.json b/package.json index a263674e..c7902891 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "lottie-react": "^2.4.1", "lucide-react": "^0.507.0", "mapbox-gl": "^3.11.0", + "mapbox-gl-compare": "^0.4.2", "next": "15.3.6", "next-themes": "^0.3.0", "open-codex": "^0.1.30",