Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ NEXT_PUBLIC_COMPOSIO_USER_ID=user@example.com
# Gemini 3.1 Pro (Google Generative AI)
GEMINI_3_PRO_API_KEY=your_gemini_3_pro_api_key_here

# Search API (https://tavily.com)
# If not set, the `search` tool is skipped and the LLM answers from its own knowledge.
TAVILY_API_KEY=your_tavily_api_key

# Supabase Credentials
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL_HERE
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY_HERE
Expand Down
8 changes: 4 additions & 4 deletions GEMINI_3.1_PRO_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ This document describes the integration of Google's Gemini 3.1 Pro model into th

Added Gemini 3.1 Pro as a provider option in the `getModel()` function with the following priority order:

1. **Gemini 3.1 Pro** - Primary choice if `GEMINI_3_PRO_API_KEY` is configured *(UPDATED PRIORITY)*
2. **xAI (Grok)** - Secondary choice if `XAI_API_KEY` is configured
1. **xAI (Grok)** - Primary choice if `XAI_API_KEY` is configured *(UPDATED PRIORITY)*
2. **Gemini 3.1 Pro** - Secondary choice if `GEMINI_3_PRO_API_KEY` is configured
3. **AWS Bedrock** - Tertiary choice if AWS credentials are configured
4. **OpenAI** - Default fallback if `OPENAI_API_KEY` is configured

Expand Down Expand Up @@ -59,10 +59,10 @@ Gemini 3.1 Pro (`gemini-3.1-pro-preview`) supports:
The provider selection follows this priority order:

```
GEMINI_3_PRO_API_KEY exists? → Use Gemini 3.1 Pro
↓ No
XAI_API_KEY exists? → Use Grok
↓ No
GEMINI_3_PRO_API_KEY exists? → Use Gemini 3.1 Pro
↓ No
AWS credentials exist? → Use AWS Bedrock
↓ No
OPENAI_API_KEY exists? → Use OpenAI (default)
Expand Down
24 changes: 22 additions & 2 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,30 @@ async function submit(formData?: FormData, skip?: boolean) {
const analysisResult = await streamResult.object;
summaryStream.done(analysisResult.summary || 'Analysis complete.');

if (analysisResult.geoJson) {
// Reconstruct standard GeoJSON from flattened schema if present
let geoJson: FeatureCollection | null = null;
if (analysisResult.geoJson && analysisResult.geoJson.features) {
geoJson = {
type: 'FeatureCollection',
features: analysisResult.geoJson.features.map(f => ({
type: 'Feature',
geometry: {
type: f.geometryType as any,
coordinates: f.coordinates as any
},
properties: {
name: f.name,
description: f.description
}
}))
};
}

if (geoJson) {
uiStream.append(
<GeoJsonLayer
id={groupeId}
data={analysisResult.geoJson as FeatureCollection}
data={geoJson}
/>
);
}
Expand Down Expand Up @@ -171,6 +190,7 @@ async function submit(formData?: FormData, skip?: boolean) {
role: 'assistant',
content: JSON.stringify({
...analysisResult,
geoJson: geoJson, // Use reconstructed GeoJSON for storage/UI
image: dataUrl,
mapboxImage: mapboxDataUrl,
googleImage: googleDataUrl
Expand Down
59 changes: 57 additions & 2 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { MapDataProvider, useMapData } from './map/map-data-context'; // Add thi
import { updateDrawingContext } from '@/lib/actions/chat'; // Import the server action
import dynamic from 'next/dynamic'
import { HeaderSearchButton } from './header-search-button'
import { ReportButton } from './report-button'

type ChatProps = {
id?: string // This is the chatId
Expand Down
64 changes: 64 additions & 0 deletions components/report-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client'

import React, { useState } from 'react'
import { Button } from '@/components/ui/button'
import { FileDown, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
import { useMapData } from '@/components/map/map-data-context'
import { useMap } from '@/components/map/map-context'
import { generateReport } from '@/lib/utils/report-generator'
import { toast } from 'sonner'

interface ReportButtonProps {
inline?: boolean
}

export const ReportButton = ({ inline = false }: ReportButtonProps) => {
const [aiState] = useAIState()
const { mapData } = useMapData()
const { map } = useMap()
const [isGenerating, setIsGenerating] = useState(false)

const handleDownloadReport = async () => {
if (isGenerating) return

setIsGenerating(true)
try {
const mapSnapshot = map ? map.getCanvas().toDataURL('image/png') : ''

const chatTitle = aiState.chatId ? `Chat-${aiState.chatId.substring(0, 8)}` : 'QCX-Analysis'

await generateReport({
messages: aiState.messages,
drawnFeatures: mapData.drawnFeatures || [],
mapSnapshot,
chatTitle
})

// Removed success toast as per user request
} catch (error) {
console.error('Failed to generate report:', error)
toast.error('Failed to generate report')
} finally {
setIsGenerating(false)
}
}

return (
<Button
variant={inline ? "default" : "ghost"}
size={inline ? "default" : "icon"}
onClick={handleDownloadReport}
title="Download PDF Report"
disabled={isGenerating}
className={inline ? "w-full" : ""}
>
{isGenerating ? (
<Loader2 className="h-[1.2rem] w-[1.2rem] animate-spin" />
) : (
<FileDown className="h-[1.2rem] w-[1.2rem]" />
)}
{inline && <span className="ml-2">Generate Report</span>}
</Button>
)
}
49 changes: 30 additions & 19 deletions components/settings/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { getSystemPrompt, saveSystemPrompt } from "../../../lib/actions/chat"
import { getSelectedModel, saveSelectedModel } from "../../../lib/actions/users"
import { useCurrentUser } from "@/lib/auth/use-current-user"
import { SettingsSkeleton } from './settings-skeleton'
import { ReportButton } from '@/components/report-button'

// Define the form schema with enum validation for roles
const settingsFormSchema = z.object({
Expand Down Expand Up @@ -166,7 +167,7 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
<Tabs.Trigger value="system-prompt" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">System Prompt</Tabs.Trigger>
<Tabs.Trigger value="model" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">Model Selection</Tabs.Trigger>
<Tabs.Trigger value="user-management" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">User Management</Tabs.Trigger>
<Tabs.Trigger value="map" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">Map</Tabs.Trigger>
<Tabs.Trigger value="report" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">Reports</Tabs.Trigger>
</Tabs.List>
<AnimatePresence mode="wait">
<motion.div
Expand Down Expand Up @@ -203,27 +204,37 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
<Tabs.Content value="user-management" className="mt-6">
<UserManagementForm form={form} />
</Tabs.Content>
<Tabs.Content value="map" className="mt-6">
<Tabs.Content value="report" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Map Provider</CardTitle>
<CardDescription>Choose the map provider to use in the application.</CardDescription>
<CardTitle>Report Generation</CardTitle>
<CardDescription>Generate and download a PDF report of your current analysis.</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup
value={mapProvider}
onValueChange={(value) => setMapProvider(value as MapProvider)}
className="space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mapbox" id="mapbox" />
<Label htmlFor="mapbox">Mapbox</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="google" id="google" />
<Label htmlFor="google">Google Maps</Label>
</div>
</RadioGroup>
<CardContent className="space-y-4">
<div className="flex flex-col space-y-4">
<p className="text-sm text-muted-foreground">
Your report will include the conversation history, current map view, analysis results, and any drawn features or measurements.
</p>
<ReportButton inline={true} />
</div>

<div className="pt-6 border-t">
<h4 className="text-sm font-medium mb-4">Map Provider Settings</h4>
<RadioGroup
value={mapProvider}
onValueChange={(value) => setMapProvider(value as MapProvider)}
className="space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mapbox" id="mapbox" />
<Label htmlFor="mapbox">Mapbox</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="google" id="google" />
<Label htmlFor="google">Google Maps</Label>
</div>
</RadioGroup>
</div>
</CardContent>
</Card>
</Tabs.Content>
Expand Down
39 changes: 2 additions & 37 deletions lib/agents/resolution-search.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,10 @@
import { CoreMessage, streamObject } from 'ai'
import { getModel } from '@/lib/utils'
import { z } from 'zod'
import { tavily } from '@tavily/core'
import { resolutionSearchSchema } from '@/lib/schema/resolution-search'

// 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, relevant current news, and temporal context.'),
geoJson: z.object({
type: z.literal('FeatureCollection'),
features: z.array(z.object({
type: z.literal('Feature'),
geometry: z.object({
type: z.string(), // e.g., 'Point', 'Polygon'
coordinates: z.any(),
}),
properties: z.object({
name: z.string(),
description: z.string().optional(),
}),
})),
}).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.'),
newsContext: z.object({
hasRecentNews: z.boolean(),
newsItems: z.array(z.object({
title: z.string(),
summary: z.string(),
relevance: z.string()
})).optional()
}).optional().describe('Recent news and events relevant to the analyzed location.')
})

export interface DrawnFeature {
id: string;
type: 'Polygon' | 'LineString';
Expand Down Expand Up @@ -162,7 +127,7 @@ Use these user-drawn areas/lines as primary areas of interest for your analysis.
4. **Coordinate Extraction:** If possible, confirm or refine the geocoordinates (latitude/longitude) of the center of the image.
5. **COG Applicability:** Determine if this location would benefit from Cloud Optimized GeoTIFF (COG) analysis for high-precision temporal or spectral data.
6. **News Integration:** Reference any recent news or events that may be relevant to the current state of the location.
7. **Structured Output:** Return your findings in a structured JSON format including summary, geoJson, and newsContext.
7. **Structured Output:** Return your findings in a structured JSON format including summary, geoJson (if any), news context, and any extracted coordinates or COG information. Use the provided schema.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the exact schema field names in the prompt.

Line 130 asks for “news context”, but the schema only accepts hasRecentNews and newsItems. That mismatch makes the model more likely to emit the wrong key or bury the data in summary, which then gets lost at validation time. Please name the concrete fields the schema expects.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/agents/resolution-search.tsx` at line 130, The prompt text in
lib/agents/resolution-search.tsx asks for "news context" which doesn't match the
schema; update the prompt (the string that contains "7. **Structured Output:**")
to use the exact schema field names: replace "news context" with the concrete
fields hasRecentNews and newsItems and instruct the model to populate those keys
(hasRecentNews: boolean, newsItems: array of objects) so the output aligns with
the expected schema and validation (locate the prompt string in the
resolution-search.tsx prompt builder / generatePrompt function and update it
accordingly).


Your analysis should be based on the visual information in the image, the temporal context provided, and your general knowledge. Do not attempt to access external websites or perform web searches beyond what has been provided.

Expand Down
27 changes: 21 additions & 6 deletions lib/agents/tools/geospatial.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,12 +346,27 @@ Uses the Mapbox Search Box Text Search API endpoint to power searching for and g
// Build arguments
const toolArgs = (() => {
switch (queryType) {
case 'directions': return { waypoints: [params.origin, params.destination], includeMapPreview: includeMap, profile: params.mode };
case 'distance': return { places: [params.origin, params.destination], includeMapPreview: includeMap, mode: params.mode || 'driving' };
case 'reverse': return { searchText: `${params.coordinates.latitude},${params.coordinates.longitude}`, includeMapPreview: includeMap, maxResults: params.maxResults || 5 };
case 'search': return { searchText: params.query, includeMapPreview: includeMap, maxResults: params.maxResults || 5, ...(params.coordinates && { proximity: `${params.coordinates.latitude},${params.coordinates.longitude}` }), ...(params.radius && { radius: params.radius }) };
case 'geocode':
case 'map': return { searchText: params.location, includeMapPreview: includeMap, maxResults: queryType === 'geocode' ? params.maxResults || 5 : undefined };
case 'directions': {
if (!params.origin || !params.destination) throw new Error("'directions' query requires origin and destination");
return { waypoints: [params.origin, params.destination], includeMapPreview: includeMap, profile: params.mode };
}
case 'distance': {
if (!params.origin || !params.destination) throw new Error("'distance' query requires origin and destination");
return { places: [params.origin, params.destination], includeMapPreview: includeMap, mode: params.mode || 'driving' };
}
case 'reverse': {
if (!params.coordinates) throw new Error("'reverse' query requires coordinates");
return { searchText: `${params.coordinates.latitude},${params.coordinates.longitude}`, includeMapPreview: includeMap, maxResults: params.maxResults || 5 };
}
case 'search': {
if (!params.query) throw new Error("'search' query requires query");
return { searchText: params.query, includeMapPreview: includeMap, maxResults: params.maxResults || 5, ...(params.coordinates && { proximity: `${params.coordinates.latitude},${params.coordinates.longitude}` }), ...(params.radius && { radius: params.radius }) };
}
case 'geocode':
case 'map': {
if (!params.location) throw new Error(`'${queryType}' query requires location`);
return { searchText: params.location, includeMapPreview: includeMap, maxResults: queryType === 'geocode' ? params.maxResults || 5 : undefined };
Comment on lines 347 to +368

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate params before opening the MCP connection.

These guards run after getConnectedMcpClient() and the listTools() probe. With the flatter schema, malformed inputs now still pay for an outbound connection and tool discovery before we reject them locally. Move the argument-building/validation step before Line 314 so bad requests short-circuit without external I/O.

Suggested direction
-    const mcpClient = await getConnectedMcpClient();
+    const toolArgs = buildToolArgs(params, includeMap);
+    const mcpClient = await getConnectedMcpClient();
     if (!mcpClient) {
       feedbackMessage = 'Geospatial functionality is unavailable. Please check configuration.';
       uiFeedbackStream.update(feedbackMessage);
       uiFeedbackStream.done();
       uiStream.update(<BotMessage content={uiFeedbackStream.value} />);
       return { type: 'MAP_QUERY_TRIGGER', originalUserInput: JSON.stringify(params), timestamp: new Date().toISOString(), mcp_response: null, error: 'MCP client initialization failed' };
     }
@@
-      const toolArgs = (() => {
-        switch (queryType) {
-          ...
-        }
-      })();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/agents/tools/geospatial.tsx` around lines 347 - 368, The parameter
validation and toolArgs construction (switch on queryType that references
params, includeMap, etc.) must be executed before establishing the MCP
connection and calling getConnectedMcpClient() / listTools(); move the guard
logic that builds toolArgs (the switch block handling 'directions', 'distance',
'reverse', 'search', 'geocode', 'map') to run first, throw early on malformed
params (missing origin/destination, coordinates, query, or location), and only
after toolArgs is successfully created proceed to call getConnectedMcpClient()
and listTools() so invalid requests short-circuit without opening external
connections.

}
}
})();

Expand Down
11 changes: 7 additions & 4 deletions lib/agents/tools/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ export interface ToolProps {

export const getTools = ({ uiStream, fullResponse, mapProvider }: ToolProps) => {
const tools: any = {
search: searchTool({
uiStream,
fullResponse
}),
retrieve: retrieveTool({
uiStream,
fullResponse
Expand All @@ -28,6 +24,13 @@ export const getTools = ({ uiStream, fullResponse, mapProvider }: ToolProps) =>
})
}

if (process.env.TAVILY_API_KEY) {
tools.search = searchTool({
uiStream,
fullResponse
})
}

if (process.env.SERPER_API_KEY) {
tools.videoSearch = videoSearchTool({
uiStream,
Expand Down
36 changes: 11 additions & 25 deletions lib/agents/tools/search.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createStreamableValue } from 'ai/rsc'
import Exa from 'exa-js'
import { tavily } from '@tavily/core'
import { searchSchema } from '@/lib/schema/search'
import { Card } from '@/components/ui/card'
Expand Down Expand Up @@ -39,22 +38,18 @@ export const searchTool = ({ uiStream, fullResponse }: ToolProps) => ({
const filledQuery =
query.length < 5 ? query + ' '.repeat(5 - query.length) : query
let searchResult
const searchAPI: 'tavily' | 'exa' = 'tavily'
try {
searchResult =
searchAPI === 'tavily'
? await tavilySearch(
filledQuery,
max_results,
search_depth,
include_answer,
topic,
time_range,
include_images,
include_image_descriptions,
include_raw_content
)
: await exaSearch(query)
searchResult = await tavilySearch(
filledQuery,
max_results,
search_depth,
include_answer,
topic,
time_range,
include_images,
include_image_descriptions,
include_raw_content
)
} catch (error) {
console.error('Search API error:', error)
hasError = true
Expand Down Expand Up @@ -101,12 +96,3 @@ async function tavilySearch(

return { ...response, results: response.results.reverse() }
}

async function exaSearch(query: string, maxResults: number = 10): Promise<any> {
const apiKey = process.env.EXA_API_KEY
const exa = new Exa(apiKey)
return exa.searchAndContents(query, {
highlights: true,
numResults: maxResults
})
}
Loading