diff --git a/app/actions.tsx b/app/actions.tsx index a1f5e915..7871933a 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -21,6 +21,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 { ResolutionCarousel } from '@/components/resolution-carousel' import { ResolutionImage } from '@/components/resolution-image' import { CopilotDisplay } from '@/components/copilot-display' import RetrieveSection from '@/components/retrieve-section' @@ -50,18 +51,29 @@ async function submit(formData?: FormData, skip?: boolean) { } if (action === 'resolution_search') { - const file = formData?.get('file') as File; + const file_mapbox = formData?.get('file_mapbox') as File; + const file_google = formData?.get('file_google') as File; + const file = (formData?.get('file') as File) || file_mapbox || file_google; const timezone = (formData?.get('timezone') as string) || 'UTC'; + const lat = formData?.get('latitude') ? parseFloat(formData.get('latitude') as string) : undefined; + const lng = formData?.get('longitude') ? parseFloat(formData.get('longitude') as string) : undefined; + const location = (lat !== undefined && lng !== undefined) ? { lat, lng } : undefined; if (!file) { throw new Error('No file provided for resolution search.'); } + const mapboxBuffer = file_mapbox ? await file_mapbox.arrayBuffer() : null; + const mapboxDataUrl = mapboxBuffer ? `data:${file_mapbox.type};base64,${Buffer.from(mapboxBuffer).toString('base64')}` : null; + + const googleBuffer = file_google ? await file_google.arrayBuffer() : null; + const googleDataUrl = googleBuffer ? `data:${file_google.type};base64,${Buffer.from(googleBuffer).toString('base64')}` : null; + const buffer = await file.arrayBuffer(); const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`; const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( - message => + (message: any) => message.role !== 'tool' && message.type !== 'followup' && message.type !== 'related' && @@ -89,7 +101,7 @@ async function submit(formData?: FormData, skip?: boolean) { async function processResolutionSearch() { try { - const streamResult = await resolutionSearch(messages, timezone, drawnFeatures); + const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location); let fullSummary = ''; for await (const partialObject of streamResult.partialObjectStream) { @@ -113,7 +125,7 @@ async function submit(formData?: FormData, skip?: boolean) { messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' }); - const sanitizedMessages: CoreMessage[] = messages.map(m => { + const sanitizedMessages: CoreMessage[] = messages.map((m: any) => { if (Array.isArray(m.content)) { return { ...m, @@ -124,7 +136,7 @@ async function submit(formData?: FormData, skip?: boolean) { }) const currentMessages = aiState.get().messages; - const sanitizedHistory = currentMessages.map(m => { + const sanitizedHistory = currentMessages.map((m: any) => { if (m.role === "user" && Array.isArray(m.content)) { return { ...m, @@ -159,7 +171,9 @@ async function submit(formData?: FormData, skip?: boolean) { role: 'assistant', content: JSON.stringify({ ...analysisResult, - image: dataUrl + image: dataUrl, + mapboxImage: mapboxDataUrl, + googleImage: googleDataUrl }), type: 'resolution_search_result' }, @@ -190,7 +204,11 @@ async function submit(formData?: FormData, skip?: boolean) { uiStream.update(
- +
); @@ -203,43 +221,20 @@ async function submit(formData?: FormData, skip?: boolean) { }; } - const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( - message => - message.role !== 'tool' && - message.type !== 'followup' && - message.type !== 'related' && - message.type !== 'end' && - message.type !== 'resolution_search_result' - ).map(m => { - if (Array.isArray(m.content)) { - return { - ...m, - content: m.content.filter((part: any) => - part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:")) - ) - } as any - } - return m - }) - - const groupeId = nanoid() - const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true' - const maxMessages = useSpecificAPI ? 5 : 10 - messages.splice(0, Math.max(messages.length - maxMessages, 0)) - + const file = !skip ? (formData?.get('file') as File) : undefined const userInput = skip ? `{"action": "skip"}` : ((formData?.get('related_query') as string) || (formData?.get('input') as string)) - if (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?') { + if (userInput && (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?')) { const definition = userInput.toLowerCase().trim() === 'what is a planet computer?' ? `A planet computer is a proprietary environment aware system that interoperates weather forecasting, mapping and scheduling using cutting edge multi-agents to streamline automation and exploration on a planet. Available for our Pro and Enterprise customers. [QCX Pricing](https://www.queue.cx/#pricing)` - : `QCX-Terra is a model garden of pixel level precision geospatial foundational models for efficient land feature predictions from satellite imagery. Available for our Pro and Enterprise customers. [QCX Pricing] (https://www.queue.cx/#pricing)`; const content = JSON.stringify(Object.fromEntries(formData!)); const type = 'input'; + const groupeId = nanoid(); aiState.update({ ...aiState.get(), @@ -299,10 +294,9 @@ async function submit(formData?: FormData, skip?: boolean) { id: nanoid(), isGenerating: isGenerating.value, component: uiStream.value, - isCollapsed: isCollapsed.value, + isCollapsed: isCollapsed.value }; } - const file = !skip ? (formData?.get('file') as File) : undefined if (!userInput && !file) { isGenerating.done(false) @@ -314,6 +308,30 @@ async function submit(formData?: FormData, skip?: boolean) { } } + const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( + (message: any) => + message.role !== 'tool' && + message.type !== 'followup' && + message.type !== 'related' && + message.type !== 'end' && + message.type !== 'resolution_search_result' + ).map((m: any) => { + if (Array.isArray(m.content)) { + return { + ...m, + content: m.content.filter((part: any) => + part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:")) + ) + } as any + } + return m + }) + + const groupeId = nanoid() + const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true' + const maxMessages = useSpecificAPI ? 5 : 10 + messages.splice(0, Math.max(messages.length - maxMessages, 0)) + const messageParts: { type: 'text' | 'image' text?: string @@ -725,12 +743,18 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { const analysisResult = JSON.parse(content as string); const geoJson = analysisResult.geoJson as FeatureCollection; const image = analysisResult.image as string; + const mapboxImage = analysisResult.mapboxImage as string; + const googleImage = analysisResult.googleImage as string; return { id, component: ( <> - {image && } + {geoJson && ( )} diff --git a/bun.lock b/bun.lock index a3de9819..f101e5d7 100644 --- a/bun.lock +++ b/bun.lock @@ -61,7 +61,7 @@ "lottie-react": "^2.4.1", "lucide-react": "^0.507.0", "mapbox-gl": "^3.11.0", - "next": "15.3.6", + "next": "15.3.8", "next-themes": "^0.3.0", "open-codex": "^0.1.30", "pg": "^8.16.2", @@ -401,7 +401,7 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - "@next/env": ["@next/env@15.3.6", "", {}, "sha512-/cK+QPcfRbDZxmI/uckT4lu9pHCfRIPBLqy88MhE+7Vg5hKrEYc333Ae76dn/cw2FBP2bR/GoK/4DU+U7by/Nw=="], + "@next/env": ["@next/env@15.3.8", "", {}, "sha512-SAfHg0g91MQVMPioeFeDjE+8UPF3j3BvHjs8ZKJAUz1BG7eMPvfCKOAgNWJ6s1MLNeP6O2InKQRTNblxPWuq+Q=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@14.2.35", "", { "dependencies": { "glob": "10.3.10" } }, "sha512-Jw9A3ICz2183qSsqwi7fgq4SBPiNfmOLmTPXKvlnzstUwyvBrtySiY+8RXJweNAs9KThb1+bYhZh9XWcNOr2zQ=="], @@ -1939,7 +1939,7 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "next": ["next@15.3.6", "", { "dependencies": { "@next/env": "15.3.6", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.5", "@next/swc-darwin-x64": "15.3.5", "@next/swc-linux-arm64-gnu": "15.3.5", "@next/swc-linux-arm64-musl": "15.3.5", "@next/swc-linux-x64-gnu": "15.3.5", "@next/swc-linux-x64-musl": "15.3.5", "@next/swc-win32-arm64-msvc": "15.3.5", "@next/swc-win32-x64-msvc": "15.3.5", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w=="], + "next": ["next@15.3.8", "", { "dependencies": { "@next/env": "15.3.8", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.5", "@next/swc-darwin-x64": "15.3.5", "@next/swc-linux-arm64-gnu": "15.3.5", "@next/swc-linux-arm64-musl": "15.3.5", "@next/swc-linux-x64-gnu": "15.3.5", "@next/swc-linux-x64-musl": "15.3.5", "@next/swc-win32-arm64-msvc": "15.3.5", "@next/swc-win32-x64-msvc": "15.3.5", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+4c5Hlr84fuaNADZbB9+ceRX9/CzwxJ+obXIGHupboB/Q1OLbSUapFs4bO8hnS/E6zV/JDX7sG1QpKVR2bguA=="], "next-themes": ["next-themes@0.3.0", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18", "react-dom": "^16.8 || ^17 || ^18" } }, "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w=="], diff --git a/components/compare-slider.tsx b/components/compare-slider.tsx new file mode 100644 index 00000000..34877148 --- /dev/null +++ b/components/compare-slider.tsx @@ -0,0 +1,91 @@ +'use client' + +import React, { useState, useRef, useEffect } from 'react' +import { cn } from '@/lib/utils' + +interface CompareSliderProps { + leftImage: string + rightImage: string + className?: string +} + +export function CompareSlider({ leftImage, rightImage, className }: CompareSliderProps) { + const [sliderPosition, setSliderPosition] = useState(50) + const containerRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(0) + + useEffect(() => { + if (!containerRef.current) return + + const observer = new ResizeObserver((entries) => { + for (let entry of entries) { + setContainerWidth(entry.contentRect.width) + } + }) + + observer.observe(containerRef.current) + return () => observer.disconnect() + }, []) + + const handleMove = (event: React.MouseEvent | React.TouchEvent) => { + if (!containerRef.current) return + + const containerRect = containerRef.current.getBoundingClientRect() + const x = 'touches' in event ? event.touches[0].clientX : (event as React.MouseEvent).clientX + const relativeX = x - containerRect.left + const position = Math.max(0, Math.min(100, (relativeX / containerRect.width) * 100)) + + setSliderPosition(position) + } + + return ( +
+ {/* Right Image (Google Satellite) */} + Google Satellite + + {/* Left Image (Mapbox) */} +
+
+ Mapbox +
+
+ + {/* Slider Handle */} +
+
+
+
+
+
+
+
+ + {/* Labels */} +
+ MAPBOX +
+
+ GOOGLE SATELLITE +
+
+ ) +} diff --git a/components/header-search-button.tsx b/components/header-search-button.tsx index 69fda09a..1923e596 100644 --- a/components/header-search-button.tsx +++ b/components/header-search-button.tsx @@ -9,9 +9,10 @@ import { useActions, useUIState } from 'ai/rsc' import { AI } from '@/app/actions' import { nanoid } from 'nanoid' import { UserMessage } from './user-message' -import { toast } from 'react-toastify' +import { toast } from 'sonner' import { useSettingsStore } from '@/lib/store/settings' import { useMapData } from './map/map-data-context' +import { compressImage } from '@/lib/utils/image-utils' // Define an interface for the actions to help TypeScript during build interface HeaderActions { @@ -48,7 +49,7 @@ export function HeaderSearchButton() { setIsAnalyzing(true) try { - setMessages(currentMessages => [ + setMessages((currentMessages: any[]) => [ ...currentMessages, { id: nanoid(), @@ -56,13 +57,42 @@ export function HeaderSearchButton() { } ]) - let blob: Blob | null = null; + let mapboxBlob: Blob | null = null; + let googleBlob: Blob | null = null; - if (mapProvider === 'mapbox') { - const canvas = map!.getCanvas() - blob = await new Promise(resolve => { + if (mapProvider === 'mapbox' && map) { + // Capture Mapbox + const canvas = map.getCanvas() + const rawMapboxBlob = await new Promise(resolve => { canvas.toBlob(resolve, 'image/png') }) + if (rawMapboxBlob) { + mapboxBlob = await compressImage(rawMapboxBlob).catch(e => { + console.error('Failed to compress Mapbox image:', e); + return rawMapboxBlob; + }); + } + + // Also fetch Google Static Map for the same view + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY; + if (apiKey) { + const center = map.getCenter(); + const zoom = Math.round(map.getZoom()); + const staticMapUrl = `https://maps.googleapis.com/maps/api/staticmap?center=${center.lat},${center.lng}&zoom=${zoom}&size=640x480&scale=2&maptype=satellite&key=${apiKey}`; + + try { + const response = await fetch(staticMapUrl); + if (response.ok) { + const rawGoogleBlob = await response.blob(); + googleBlob = await compressImage(rawGoogleBlob).catch(e => { + console.error('Failed to compress Google image:', e); + return rawGoogleBlob; + }); + } + } catch (e) { + console.error('Failed to fetch Google static map during Mapbox session:', e); + } + } } else if (mapProvider === 'google') { const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY if (!apiKey || !mapData.cameraState) { @@ -79,21 +109,36 @@ export function HeaderSearchButton() { if (!response.ok) { throw new Error('Failed to fetch static map image.'); } - blob = await response.blob(); + const rawGoogleBlob = await response.blob(); + googleBlob = await compressImage(rawGoogleBlob).catch(e => { + console.error('Failed to compress Google image:', e); + return rawGoogleBlob; + }); } - if (!blob) { + if (!mapboxBlob && !googleBlob) { throw new Error('Failed to capture map image.') } const formData = new FormData() - formData.append('file', blob, 'map_capture.png') + if (mapboxBlob) formData.append('file_mapbox', mapboxBlob, 'mapbox_capture.png') + if (googleBlob) formData.append('file_google', googleBlob, 'google_capture.png') + + // Keep 'file' for backward compatibility if needed, or just use the first available + formData.append('file', (mapboxBlob || googleBlob)!, 'map_capture.png') + formData.append('action', 'resolution_search') formData.append('timezone', mapData.currentTimezone || 'UTC') formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || [])) + const center = mapProvider === 'mapbox' && map ? map.getCenter() : mapData.cameraState?.center; + if (center) { + formData.append('latitude', center.lat.toString()) + formData.append('longitude', center.lng.toString()) + } + const responseMessage = await actions.submit(formData) - setMessages(currentMessages => [...currentMessages, responseMessage as any]) + setMessages((currentMessages: any[]) => [...currentMessages, responseMessage as any]) } catch (error) { console.error('Failed to perform resolution search:', error) toast.error('An error occurred while analyzing the map.') diff --git a/components/map/mapbox-map.tsx b/components/map/mapbox-map.tsx index eecd7f54..55552b5d 100644 --- a/components/map/mapbox-map.tsx +++ b/components/map/mapbox-map.tsx @@ -5,8 +5,8 @@ import mapboxgl from 'mapbox-gl' import MapboxDraw from '@mapbox/mapbox-gl-draw' import * as turf from '@turf/turf' import tzlookup from 'tz-lookup' -import { toast } from 'react-toastify' -import 'react-toastify/dist/ReactToastify.css' +import { toast } from 'sonner' + import 'mapbox-gl/dist/mapbox-gl.css' import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css' import { useMapToggle, MapToggleEnum } from '../map-toggle-context' diff --git a/components/resolution-carousel.tsx b/components/resolution-carousel.tsx new file mode 100644 index 00000000..e6fa46c8 --- /dev/null +++ b/components/resolution-carousel.tsx @@ -0,0 +1,151 @@ +'use client' + +import React from 'react' +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel" +import { ResolutionImage } from './resolution-image' +import { Button } from './ui/button' +import { useActions, useUIState } from 'ai/rsc' +import { AI } from '@/app/actions' +import { nanoid } from 'nanoid' +import { UserMessage } from './user-message' +import { toast } from 'sonner' +import { CompareSlider } from './compare-slider' +import { compressImage } from '@/lib/utils/image-utils' + +interface ResolutionCarouselProps { + mapboxImage?: string | null + googleImage?: string | null + initialImage?: string | null +} + +export function ResolutionCarousel({ mapboxImage, googleImage, initialImage }: ResolutionCarouselProps) { + const actions = useActions() as any + const [, setMessages] = useUIState() + const [isAnalyzing, setIsAnalyzing] = React.useState(false) + + const handleQCXAnalysis = async () => { + if (!googleImage) return + setIsAnalyzing(true) + + try { + const response = await fetch(googleImage) + const rawBlob = await response.blob() + const blob = await compressImage(rawBlob).catch(e => { + console.error('Failed to compress image for analysis:', e); + return rawBlob; + }); + + setMessages((currentMessages: any[]) => [ + ...currentMessages, + { + id: nanoid(), + component: + } + ]) + + const formData = new FormData() + formData.append('file', blob, 'google_analysis.png') + formData.append('action', 'resolution_search') + + const responseMessage = await actions.submit(formData) + setMessages((currentMessages: any[]) => [...currentMessages, responseMessage as any]) + } catch (error) { + console.error('Failed to perform QCX-TERRA ANALYSIS:', error) + toast.error('An error occurred during analysis.') + } finally { + setIsAnalyzing(false) + } + } + + const slides: Array<{ type: 'compare', left: string, right: string } | { type: 'image', src: string, showAnalysis: boolean, label: string }> = [] + + // Slide 1: Comparison (if both exist) + if (mapboxImage && googleImage) { + slides.push({ + type: 'compare', + left: mapboxImage, + right: googleImage + }) + } + + // Individual slides + if (mapboxImage) slides.push({ type: 'image', src: mapboxImage, showAnalysis: false, label: 'MAPBOX' }) + if (googleImage) slides.push({ type: 'image', src: googleImage, showAnalysis: true, label: 'GOOGLE SATELLITE' }) + + // Fallback + if (slides.length === 0 && initialImage) { + slides.push({ type: 'image', src: initialImage, showAnalysis: false, label: 'MAP CAPTURE' }) + } + + if (slides.length === 0) return null + + if (slides.length === 1) { + const item = slides[0] + if (item.type === 'image') { + return ( +
+ + {item.showAnalysis && ( + + )} +
+ ) + } + } + + return ( +
+ + + {slides.map((slide, index) => ( + +
+ {slide.type === 'compare' ? ( + + ) : ( + <> + + {slide.showAnalysis && ( + + )} +
+ {slide.label} +
+ + )} +
+
+ ))} +
+ {slides.length > 1 && ( +
+ + +
+ )} +
+
+ ) +} diff --git a/components_chat_patch.patch b/components_chat_patch.patch deleted file mode 100644 index 17df116b..00000000 --- a/components_chat_patch.patch +++ /dev/null @@ -1,24 +0,0 @@ ---- components/chat.tsx -+++ components/chat.tsx -@@ -156,10 +156,7 @@ - { - setInput(message) -- setTimeout(() => { -- setIsSubmitting(true) -- }, 0) -+ setIsSubmitting(true) - }} - /> - ) : ( -@@ -198,10 +195,7 @@ - { - setInput(message) -- setTimeout(() => { -- setIsSubmitting(true) -- }, 0) -+ setIsSubmitting(true) - }} - /> - ) : ( diff --git a/lib/agents/resolution-search.tsx b/lib/agents/resolution-search.tsx index 737551e8..1bcc3290 100644 --- a/lib/agents/resolution-search.tsx +++ b/lib/agents/resolution-search.tsx @@ -21,6 +21,14 @@ const resolutionSearchSchema = z.object({ }), })), }).describe('A GeoJSON object containing points of interest and classified land features to be overlaid on the map.'), + extractedCoordinates: z.object({ + latitude: z.number(), + longitude: z.number() + }).optional().describe('The extracted geocoordinates of the center of the image.'), + cogInfo: z.object({ + applicable: z.boolean(), + description: z.string().optional() + }).optional().describe('Information about whether Cloud Optimized GeoTIFF (COG) data is applicable or available for this area.') }) export interface DrawnFeature { @@ -30,7 +38,7 @@ export interface DrawnFeature { geometry: any; } -export async function resolutionSearch(messages: CoreMessage[], timezone: string = 'UTC', drawnFeatures?: DrawnFeature[]) { +export async function resolutionSearch(messages: CoreMessage[], timezone: string = 'UTC', drawnFeatures?: DrawnFeature[], location?: { lat: number, lng: number }) { const localTime = new Date().toLocaleString('en-US', { timeZone: timezone, hour: '2-digit', @@ -46,6 +54,8 @@ export async function resolutionSearch(messages: CoreMessage[], timezone: string As a geospatial analyst, your task is to analyze the provided satellite image of a geographic location. The current local time at this location is ${localTime}. +${location ? `The coordinates provided for this image are: Latitude ${location.lat}, Longitude ${location.lng}.` : ''} + ${drawnFeatures && drawnFeatures.length > 0 ? `The user has drawn the following features on the map for your reference: ${drawnFeatures.map(f => `- ${f.type} (${f.measurement}): ${JSON.stringify(f.geometry)}`).join('\n')} Use these user-drawn areas/lines as primary areas of interest for your analysis.` : ''} @@ -54,7 +64,9 @@ Your analysis should be comprehensive and include the following components: 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. +3. **Coordinate Extraction:** If possible, confirm or refine the geocoordinates (latitude/longitude) of the center of the image. +4. **COG Applicability:** Determine if this location would benefit from Cloud Optimized GeoTIFF (COG) analysis for high-precision temporal or spectral data. +5. **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. 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. @@ -64,9 +76,9 @@ Analyze the user's prompt and the image to provide a holistic understanding of t const filteredMessages = messages.filter(msg => msg.role !== 'system'); // Check if any message contains an image (resolution search is specifically for image analysis) - const hasImage = messages.some(message => + const hasImage = messages.some((message: any) => Array.isArray(message.content) && - message.content.some(part => part.type === 'image') + message.content.some((part: any) => part.type === 'image') ) // Use streamObject to get partial results. diff --git a/lib/utils/image-utils.ts b/lib/utils/image-utils.ts new file mode 100644 index 00000000..0c2d5e6c --- /dev/null +++ b/lib/utils/image-utils.ts @@ -0,0 +1,51 @@ +/** + * Resizes and compresses an image blob. + */ +export async function compressImage(blob: Blob, maxWidth = 1024, maxHeight = 1024, quality = 0.8): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + let width = img.width; + let height = img.height; + + if (width > height) { + if (width > maxWidth) { + height *= maxWidth / width; + width = maxWidth; + } + } else { + if (height > maxHeight) { + width *= maxHeight / height; + height = maxHeight; + } + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Failed to get canvas context')); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob( + (compressedBlob) => { + if (compressedBlob) { + resolve(compressedBlob); + } else { + reject(new Error('Failed to compress image')); + } + }, + 'image/jpeg', + quality + ); + }; + + img.onerror = () => reject(new Error('Failed to load image for compression')); + img.src = URL.createObjectURL(blob); + }); +}