Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 116 additions & 2 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import {
getAIState,
getMutableAIState
} from 'ai/rsc'
import { CoreMessage, ToolResultPart } from 'ai'
import { CoreMessage, ToolResultPart, generateObject } from 'ai'
import { nanoid } from 'nanoid'
import { z } from 'zod'
import type { FeatureCollection } from 'geojson'
import { Spinner } from '@/components/ui/spinner'
import { Section } from '@/components/section'
import { FollowupPanel } from '@/components/followup-panel'
import { inquire, researcher, taskManager, querySuggestor, resolutionSearch } from '@/lib/agents'
import { getModel } from '@/lib/utils'
// Removed import of useGeospatialToolMcp as it no longer exists and was incorrectly used here.
// The geospatialTool (if used by agents like researcher) now manages its own MCP client.
import { writer } from '@/lib/agents/writer'
Expand All @@ -26,13 +28,42 @@ import { GeoJsonLayer } from '@/components/map/geojson-layer'
import { CopilotDisplay } from '@/components/copilot-display'
import RetrieveSection from '@/components/retrieve-section'
import { VideoSearchSection } from '@/components/video-search-section'
import { MapQueryHandler } from '@/components/map/map-query-handler' // Add this import
import { MapQueryHandler } from '@/components/map/map-query-handler'
import { MapImageOverlay } from '@/components/map/map-image-overlay'

// Define the type for related queries
type RelatedQueries = {
items: { query: string }[]
}

async function extractCoordinatesFromMapImage(imageDataUrl: string) {
'use server'
const result = await generateObject({
model: getModel(),
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Understand the image uploaded by the user, if its a map proceed to extract all the information that you can from the image. We have to place it in the system if it does not have coordinates place it at the center of mapbox if does, Extract the geographic coordinates from this map image. Look for latitude/longitude labels, coordinate grid lines, or scale information. Return the corner coordinates.'
},
{ type: 'image', image: imageDataUrl }
]
}
],
schema: z.object({
topLeft: z.object({ lat: z.number(), lng: z.number() }),
topRight: z.object({ lat: z.number(), lng: z.number() }),
bottomRight: z.object({ lat: z.number(), lng: z.number() }),
bottomLeft: z.object({ lat: z.number(), lng: z.number() }),
confidence: z.number().describe('0-1 confidence score')
})
})

return result.object
}

// Removed mcp parameter from submit, as geospatialTool now handles its client.
async function submit(formData?: FormData, skip?: boolean) {
'use server'
Expand Down Expand Up @@ -238,6 +269,54 @@ async function submit(formData?: FormData, skip?: boolean) {
image: dataUrl,
mimeType: file.type
})

try {
const coords = await extractCoordinatesFromMapImage(dataUrl)
if (coords.confidence > 0.7) {
const coordinates: [
[number, number],
[number, number],
[number, number],
[number, number]
] = [
[coords.topLeft.lng, coords.topLeft.lat],
[coords.topRight.lng, coords.topRight.lat],
[coords.bottomRight.lng, coords.bottomRight.lat],
[coords.bottomLeft.lng, coords.bottomLeft.lat]
]
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'assistant',
content: JSON.stringify({ imageUrl: dataUrl, coordinates }),
type: 'image_overlay'
}
]
})
} else {
console.warn('Low confidence in coordinate extraction')
throw new Error('Low confidence')
}
} catch (error) {
console.error('Failed to extract coordinates:', error)
const mapCenter = formData?.get('map_center') as string | undefined
const center = mapCenter ? JSON.parse(mapCenter) : [0, 0]
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'assistant',
content: JSON.stringify({ imageUrl: dataUrl, center }),
type: 'image_overlay'
}
]
})
}
} else if (file.type === 'text/plain') {
const textContent = Buffer.from(buffer).toString('utf-8')
const existingTextPart = messageParts.find(p => p.type === 'text')
Expand Down Expand Up @@ -591,6 +670,41 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
const answer = createStreamableValue()
answer.done(content)
switch (type) {
case 'image_overlay': {
const data = JSON.parse(content as string)
let coordinates: [
[number, number],
[number, number],
[number, number],
[number, number]
]

if (data.coordinates) {
// New format: direct coordinates
coordinates = data.coordinates
} else {
// Old format: fallback to center-based
const [lng, lat] = data.center
const halfSize = 0.5
coordinates = [
[lng - halfSize, lat + halfSize],
[lng + halfSize, lat + halfSize],
[lng + halfSize, lat - halfSize],
[lng - halfSize, lat - halfSize]
]
}

return {
id,
component: (
<MapImageOverlay
id={id}
imageUrl={data.imageUrl}
coordinates={coordinates}
/>
)
}
}
case 'response':
return {
id,
Expand Down
9 changes: 7 additions & 2 deletions components/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useEffect, useState, useRef, ChangeEvent, forwardRef, useImperativeHandle } from 'react'
import type { AI, UIState } from '@/app/actions'
import { useUIState, useActions } from 'ai/rsc'
// Removed import of useGeospatialToolMcp as it's no longer used/available
import { useMap } from './map/map-context'
import { cn } from '@/lib/utils'
import { UserMessage } from './user-message'
import { Button } from './ui/button'
Expand All @@ -24,7 +24,7 @@ export interface ChatPanelRef {
export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, input, setInput }, ref) => {
const [, setMessages] = useUIState<typeof AI>()
const { submit, clearChat } = useActions()
// Removed mcp instance as it's no longer passed to submit
const { map } = useMap()
const [isMobile, setIsMobile] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
Expand Down Expand Up @@ -99,6 +99,11 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
formData.append('file', selectedFile)
}

if (map) {
const center = map.getCenter()
formData.append('map_center', JSON.stringify([center.lng, center.lat]))
}

setInput('')
clearAttachment()

Expand Down
114 changes: 114 additions & 0 deletions components/map/map-image-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use client'

import { useMap } from '@/components/map/map-context'
import { useEffect } from 'react'
import { LngLatBounds } from 'mapbox-gl'

interface MapImageOverlayProps {
id: string
imageUrl: string
coordinates: [
[number, number],
[number, number],
[number, number],
[number, number]
]
beforeId?: string
opacity?: number
}

export function MapImageOverlay({
id,
imageUrl,
coordinates,
beforeId,
opacity = 0.85
}: MapImageOverlayProps) {
const { map } = useMap()

useEffect(() => {
if (
!map ||
!imageUrl ||
!Array.isArray(coordinates) ||
coordinates.length < 4 ||
!coordinates.every(
c => Array.isArray(c) && c.length >= 2 && !isNaN(c[0]) && !isNaN(c[1])
)
) {
return
}

const sourceId = `image-overlay-source-${id}`
const layerId = `image-overlay-layer-${id}`
let attached = false

const onMapLoad = () => {
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'image',
url: imageUrl,
coordinates: coordinates
})
}

if (!map.getLayer(layerId)) {
if (beforeId) {
map.addLayer(
{
id: layerId,
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': opacity,
'raster-fade-duration': 0
}
},
beforeId
)
} else {
map.addLayer({
id: layerId,
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': opacity,
'raster-fade-duration': 0
}
})
}
}

const bounds = coordinates.reduce(
(b, c) => b.extend(c),
new LngLatBounds(coordinates[0], coordinates[0])
)

map.fitBounds(bounds, {
padding: 20,
duration: 1000
})
}

if (map.isStyleLoaded()) {
onMapLoad()
} else {
attached = true
map.once('load', onMapLoad)
}

return () => {
if (attached) {
map.off('load', onMapLoad)
}
if (map.getLayer(layerId)) {
map.removeLayer(layerId)
}
if (map.getSource(sourceId)) {
map.removeSource(sourceId)
}
}
}, [map, id, imageUrl, coordinates, beforeId, opacity])

return null
}
4 changes: 1 addition & 3 deletions lib/agents/tools/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createStreamableUI } from 'ai/rsc'
import { retrieveTool } from './retrieve'
import { searchTool } from './search'
import { videoSearchTool } from './video-search'
import { geospatialTool } from './geospatial' // Removed useGeospatialToolMcp import
import { geospatialTool } from './geospatial'

export interface ToolProps {
uiStream: ReturnType<typeof createStreamableUI>
Expand All @@ -21,10 +21,8 @@ export const getTools = ({ uiStream, fullResponse }: ToolProps) => {
uiStream,
fullResponse
}),
// geospatialTool now only requires uiStream
geospatialQueryTool: geospatialTool({
uiStream
// mcp: mcp || null // Removed mcp argument
})
}

Expand Down
3 changes: 2 additions & 1 deletion lib/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export type AIMessage = {
| 'tool'
| 'followup'
| 'end'
| 'drawing_context' // Added custom type for drawing context messages
| 'drawing_context'
| 'resolution_search_result'
| 'image_overlay'
}
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@
"next-themes": "^0.3.0",
"open-codex": "^0.1.30",
"pg": "^8.16.2",
"QCX": ".",
"radix-ui": "^1.3.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
Expand Down