-
-
Notifications
You must be signed in to change notification settings - Fork 6
feat: autonomous map navigation for address inputs #522
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6eeae28
160e082
ae085cf
73c385c
1508c04
c8c028b
e2c0615
43179e7
7d45e02
5623831
44e86b6
0a48018
6acfbe5
8ae549a
3495da1
ba36e58
6431b41
8a04d49
10ab3fe
250283e
1b56819
9f79929
6554775
bfe62f9
9450b15
e95a25b
f80c87f
c4278e9
813d264
c868dcd
3b5ed27
3984b9b
f45f687
67c26d5
a842df1
184f678
895bf37
dc345b9
86013ed
2ba1f9e
dda7a32
23a1d3f
885dbbe
dd812c1
00c2e1a
54d9d6e
c9bd9b2
85a85c5
07f633d
8fb9809
6289c7f
209f9be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,11 +21,13 @@ 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 { MapDataUpdater } from '@/components/map/map-data-updater' | ||
| import { ResolutionImage } from '@/components/resolution-image' | ||
| 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' | ||
| import { geospatialTool } from '@/lib/agents/tools/geospatial' | ||
|
|
||
| // Define the type for related queries | ||
| type RelatedQueries = { | ||
|
|
@@ -232,6 +234,37 @@ async function submit(formData?: FormData, skip?: boolean) { | |
| : ((formData?.get('related_query') as string) || | ||
| (formData?.get('input') as string)) | ||
|
|
||
| let isGeoJsonInput = false | ||
| if (userInput) { | ||
| try { | ||
| const trimmedInput = userInput.trim() | ||
| if ((trimmedInput.startsWith('{') && trimmedInput.endsWith('}')) || (trimmedInput.startsWith('[') && trimmedInput.endsWith(']'))) { | ||
| const geoJson = JSON.parse(trimmedInput) | ||
| if (geoJson.type === 'FeatureCollection' || geoJson.type === 'Feature') { | ||
| isGeoJsonInput = true | ||
| const geoJsonId = nanoid() | ||
| aiState.update({ | ||
| ...aiState.get(), | ||
| messages: [ | ||
| ...aiState.get().messages, | ||
| { | ||
| id: geoJsonId, | ||
| role: 'assistant', | ||
| content: JSON.stringify({ data: geoJson, filename: 'Pasted GeoJSON' }), | ||
| type: 'geojson_upload' | ||
| } | ||
| ] | ||
| }) | ||
| uiStream.append( | ||
| <MapDataUpdater id={geoJsonId} data={geoJson} filename="Pasted GeoJSON" /> | ||
| ) | ||
| } | ||
| } | ||
| } catch (e) { | ||
| // Not a valid JSON, ignore | ||
| } | ||
| } | ||
|
|
||
| if (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)` | ||
|
|
@@ -322,6 +355,8 @@ async function submit(formData?: FormData, skip?: boolean) { | |
| }[] = [] | ||
|
|
||
| if (userInput) { | ||
| // If it's a GeoJSON input, we still want to keep it in the message history for the AI to see, | ||
| // but we might want to truncate it if it's huge. For now, just pass it. | ||
| messageParts.push({ type: 'text', text: userInput }) | ||
| } | ||
|
|
||
|
|
@@ -336,8 +371,39 @@ async function submit(formData?: FormData, skip?: boolean) { | |
| image: dataUrl, | ||
| mimeType: file.type | ||
| }) | ||
| } else if (file.type === 'text/plain') { | ||
| } else if (file.type === 'text/plain' || file.name.endsWith('.geojson') || file.type === 'application/geo+json') { | ||
| const textContent = Buffer.from(buffer).toString('utf-8') | ||
| const isGeoJson = file.name.endsWith('.geojson') || file.type === 'application/geo+json' | ||
|
|
||
| if (isGeoJson) { | ||
| try { | ||
| const geoJson = JSON.parse(textContent) | ||
| if (geoJson.type === 'FeatureCollection' || geoJson.type === 'Feature') { | ||
| const geoJsonId = nanoid() | ||
| // Add a special message to track the GeoJSON upload | ||
| aiState.update({ | ||
| ...aiState.get(), | ||
| messages: [ | ||
| ...aiState.get().messages, | ||
| { | ||
| id: geoJsonId, | ||
| role: 'assistant', | ||
| content: JSON.stringify({ data: geoJson, filename: file.name }), | ||
| type: 'geojson_upload' | ||
| } | ||
| ] | ||
| }) | ||
|
Comment on lines
+384
to
+395
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Duplicate GeoJSON detection logic between paste (lines 238–266) and file upload (lines 378–405). The GeoJSON parsing, AI state update, and 🤖 Prompt for AI Agents |
||
|
|
||
| // Immediately append the updater to the UI stream | ||
| uiStream.append( | ||
| <MapDataUpdater id={geoJsonId} data={geoJson} filename={file.name} /> | ||
| ) | ||
| } | ||
| } catch (e) { | ||
| console.error('Failed to parse GeoJSON:', e) | ||
| } | ||
| } | ||
|
|
||
| const existingTextPart = messageParts.find(p => p.type === 'text') | ||
| if (existingTextPart) { | ||
| existingTextPart.text = `${textContent}\n\n${existingTextPart.text}` | ||
|
|
@@ -383,6 +449,47 @@ async function submit(formData?: FormData, skip?: boolean) { | |
| const currentSystemPrompt = (await getSystemPrompt(userId)) || '' | ||
| const mapProvider = formData?.get('mapProvider') as 'mapbox' | 'google' | ||
|
|
||
| // Autonomous Map Navigation: Check if input looks like an address | ||
| const isPotentialAddress = (text: string) => { | ||
| // Simple heuristic: contains numbers and multiple words, or specific keywords | ||
| const addressKeywords = ['palace', 'street', 'st', 'avenue', 'ave', 'road', 'rd', 'boulevard', 'blvd', 'drive', 'dr', 'lane', 'ln', 'court', 'ct', 'square', 'sq', 'parkway', 'pkwy']; | ||
| const words = text.toLowerCase().split(/\s+/); | ||
| const hasNumber = /\d+/.test(text); | ||
| const hasKeyword = words.some(word => addressKeywords.includes(word)); | ||
| const isCoordinate = /^-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?$/.test(text.trim()); | ||
|
|
||
| return (hasNumber && words.length >= 2) || hasKeyword || isCoordinate; | ||
| }; | ||
|
Comment on lines
+452
to
+462
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The condition Consider tightening the heuristic — e.g., require both a number and a keyword, or use a more specific address regex pattern, or require a minimum of 3+ words when relying on the number heuristic alone. Suggested tightening- return (hasNumber && words.length >= 2) || hasKeyword || isCoordinate;
+ return (hasNumber && hasKeyword) || isCoordinate;This still won't be perfect but drastically reduces false positives. 🤖 Prompt for AI Agents |
||
|
|
||
| if (userInput && isPotentialAddress(userInput)) { | ||
| console.log('[AutonomousMap] Detected potential address:', userInput); | ||
| // Trigger geospatial tool directly for immediate map update | ||
| const geoTool = geospatialTool({ uiStream, mapProvider }); | ||
|
|
||
| // Run geocoding in the background or wait briefly | ||
| // We don't await it here to avoid blocking the main AI response, | ||
| // but we want it to start immediately. | ||
| (async () => { | ||
| try { | ||
| const isCoordinate = /^-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?$/.test(userInput.trim()); | ||
| const result = await geoTool.execute( | ||
| isCoordinate | ||
| ? { queryType: 'reverse', coordinates: { | ||
| latitude: parseFloat(userInput.split(',')[0]), | ||
| longitude: parseFloat(userInput.split(',')[1]) | ||
| } } | ||
| : { queryType: 'geocode', location: userInput } | ||
| ); | ||
|
|
||
| if (result && result.type === 'MAP_QUERY_TRIGGER') { | ||
| uiStream.append(<MapQueryHandler toolOutput={result} />); | ||
| } | ||
| } catch (error) { | ||
| console.error('[AutonomousMap] Quick geocode failed:', error); | ||
| } | ||
| })(); | ||
| } | ||
|
Comment on lines
+472
to
+491
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fire-and-forget async geocoding races with The geocoding IIFE runs concurrently with This is a race condition that will surface as an unhandled runtime error, potentially crashing the request or silently dropping the map result. Proposed fix: coordinate the geocoding with processEventsStore the geocoding promise and + let autonomousMapPromise: Promise<void> | null = null;
+
if (userInput && isPotentialAddress(userInput)) {
console.log('[AutonomousMap] Detected potential address:', userInput);
const geoTool = geospatialTool({ uiStream, mapProvider });
- (async () => {
+ autonomousMapPromise = (async () => {
try {
// ... geocoding logic ...
} catch (error) {
console.error('[AutonomousMap] Quick geocode failed:', error);
}
})();
}Then inside + if (autonomousMapPromise) await autonomousMapPromise;
isGenerating.done(false)
uiStream.done()🤖 Prompt for AI Agents |
||
|
|
||
|
Comment on lines
+452
to
+492
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Autonomous geocoding is triggered for any input matching the heuristic, including coordinates, but the heuristic is extremely permissive (e.g., any message containing a number and two words; keywords like Additionally:
Net: this feature risks noisy calls, rate limiting, and surprising UI updates. SuggestionTighten and gate the autonomous navigation:
If you want, I can add a commit that:
Reply with "@CharlieHelps yes please" if you’d like me to add that commit. |
||
| async function processEvents() { | ||
| let action: any = { object: { next: 'proceed' } } | ||
| if (!skip) { | ||
|
|
@@ -644,10 +751,19 @@ export const AI = createAI<AIState, UIState>({ | |
| export const getUIStateFromAIState = (aiState: AIState): UIState => { | ||
| const chatId = aiState.chatId | ||
| const isSharePage = aiState.isSharePage | ||
|
|
||
| // Filter messages to only include the last 'data' message if multiple exist | ||
| const lastDataMessageIndex = [...aiState.messages].reverse().findIndex(m => m.role === 'data') | ||
| const actualLastDataIndex = lastDataMessageIndex === -1 ? -1 : aiState.messages.length - 1 - lastDataMessageIndex | ||
|
|
||
| return aiState.messages | ||
| .map((message, index) => { | ||
| const { role, content, id, type, name } = message | ||
|
|
||
| if (role === 'data' && index !== actualLastDataIndex) { | ||
| return null | ||
| } | ||
|
Comment on lines
759
to
+765
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Add explicit Proposed fix }
break
+ return null
case 'assistant': }
break
+ return null
case 'tool':Or better yet, refactor so each case 'user':
- switch (type) {
+ return (() => { switch (type) {
// ...
- }
- break
+ default: return null
+ } })()Also applies to: 803-804, 864-865 🧰 Tools🪛 Biome (2.3.14)[error] 760-760: This callback passed to map() iterable method should always return a value. Add missing return statements so that this callback returns a value on all execution paths. (lint/suspicious/useIterableCallbackReturn) 🤖 Prompt for AI Agents |
||
|
|
||
| if ( | ||
| !type || | ||
| type === 'end' || | ||
|
|
@@ -738,6 +854,13 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { | |
| ) | ||
| } | ||
| } | ||
| case 'geojson_upload': { | ||
| const { data, filename } = JSON.parse(content as string) | ||
| return { | ||
| id, | ||
| component: <MapDataUpdater id={id} data={data} filename={filename} /> | ||
| } | ||
| } | ||
|
Comment on lines
+857
to
+863
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This adds a new You already wrap parsing in other branches (e.g. SuggestionWrap the case 'geojson_upload': {
try {
const { data, filename } = JSON.parse(content as string)
return { id, component: <MapDataUpdater id={id} data={data} filename={filename} /> }
} catch (e) {
console.error('Error parsing geojson_upload message:', id, e)
return { id, component: null }
}
}Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion. |
||
| } | ||
| break | ||
| case 'tool': | ||
|
|
@@ -813,6 +936,26 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { | |
| } | ||
| } | ||
| break | ||
| case 'data': | ||
| try { | ||
| const contextData = JSON.parse(content as string) | ||
| if (contextData.uploadedGeoJson && Array.isArray(contextData.uploadedGeoJson)) { | ||
| return { | ||
| id, | ||
| component: ( | ||
| <> | ||
| {contextData.uploadedGeoJson.map((item: any) => ( | ||
| <MapDataUpdater key={item.id} id={item.id} data={item.data} filename={item.filename} /> | ||
| ))} | ||
| </> | ||
| ) | ||
| } | ||
| } | ||
| return { id, component: null } | ||
| } catch (e) { | ||
| console.error('Error parsing data message:', e) | ||
| return { id, component: null } | ||
| } | ||
| default: | ||
| return { | ||
| id, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -48,14 +48,11 @@ export default async function SearchPage({ params }: SearchPageProps) { | |||||||||
| const initialMessages: AIMessage[] = dbMessages.map((dbMsg): AIMessage => { | ||||||||||
| return { | ||||||||||
| id: dbMsg.id, | ||||||||||
| role: dbMsg.role as AIMessage['role'], // Cast role, ensure AIMessage['role'] includes all dbMsg.role possibilities | ||||||||||
| role: dbMsg.role as AIMessage['role'], | ||||||||||
| content: dbMsg.content, | ||||||||||
| createdAt: dbMsg.createdAt ? new Date(dbMsg.createdAt) : undefined, | ||||||||||
| // 'type' and 'name' are not in the basic Drizzle 'messages' schema. | ||||||||||
| // These would be undefined unless specific logic is added to derive them. | ||||||||||
| // For instance, if a message with role 'tool' should have a 'name', | ||||||||||
| // or if some messages have a specific 'type' based on content or other flags. | ||||||||||
| // This mapping assumes standard user/assistant messages primarily. | ||||||||||
| type: dbMsg.type as AIMessage['type'], | ||||||||||
| name: dbMsg.toolName as string, | ||||||||||
|
Comment on lines
+54
to
+55
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unsafe cast: The Proposed fix- type: dbMsg.type as AIMessage['type'],
- name: dbMsg.toolName as string,
+ type: (dbMsg.type ?? undefined) as AIMessage['type'],
+ name: dbMsg.toolName ?? undefined,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| }; | ||||||||||
| }); | ||||||||||
|
|
||||||||||
|
|
||||||||||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -215,7 +215,8 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i | |
| ref={fileInputRef} | ||
| onChange={handleFileChange} | ||
| className="hidden" | ||
| accept="text/plain,image/png,image/jpeg,image/webp" | ||
| accept="text/plain,image/png,image/jpeg,image/webp,.geojson,application/geo+json" | ||
| data-testid="file-upload-input" | ||
|
Comment on lines
+218
to
+219
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial GeoJSON files accepted but not validated or previewed distinctly. The Consider adding a content part type for GeoJSON files (or at minimum a text indicator) so the user message reflects that a GeoJSON was attached. Also, the 🤖 Prompt for AI Agents |
||
| /> | ||
| {!isMobile && ( | ||
| <Button | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
GeoJSON pasted by the user is stored with
role: 'assistant'— incorrect attribution.When a user pastes GeoJSON text, the message is added to AI state with
role: 'assistant'(line 252). This misattributes user input to the assistant, which could confuse downstream logic that relies on role for conversation flow.Consider using
role: 'user'withtype: 'geojson_upload', or arole: 'data'to keep it neutral.🤖 Prompt for AI Agents