Skip to content
Merged
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
Binary file removed CC BY-NC 4.0.docx
Binary file not shown.
129 changes: 95 additions & 34 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import { Spinner } from '@/components/ui/spinner'
import { Section } from '@/components/section'
import { FollowupPanel } from '@/components/followup-panel'
import { inquire, researcher, taskManager, querySuggestor, resolutionSearch, type DrawnFeature } from '@/lib/agents'
// 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'
import { saveChat, getSystemPrompt } from '@/lib/actions/chat'
import { saveChat, getSystemPrompt } from '@/lib/actions/chat' // Added getSystemPrompt
import { Chat, AIMessage } from '@/lib/types'
import { UserMessage } from '@/components/user-message'
import { BotMessage } from '@/components/message'
Expand All @@ -25,13 +27,14 @@ 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 { MapQueryHandler } from '@/components/map/map-query-handler' // Add this import

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

// Removed mcp parameter from submit, as geospatialTool now handles its client.
async function submit(formData?: FormData, skip?: boolean) {
'use server'

Expand All @@ -41,25 +44,42 @@ async function submit(formData?: FormData, skip?: boolean) {
const isCollapsed = createStreamableValue(false)

const action = formData?.get('action') as string;
const drawnFeaturesString = formData?.get('drawnFeatures') as string;
let drawnFeatures: DrawnFeature[] = [];
try {
drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : [];
} catch (e) {
console.error('Failed to parse drawnFeatures:', e);
}

if (action === 'resolution_search') {
const file = formData?.get('file') as File;
const mapboxFile = formData?.get('mapboxFile') as File;
const googleFile = formData?.get('googleFile') as File;
const legacyFile = formData?.get('file') as File;
const timezone = (formData?.get('timezone') as string) || 'UTC';
const drawnFeaturesString = formData?.get('drawnFeatures') as string;
let drawnFeatures: DrawnFeature[] = [];
try {
drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : [];
} catch (e) {
console.error('Failed to parse drawnFeatures:', e);
}

let mapboxDataUrl = '';
let googleDataUrl = '';

if (!file) {
throw new Error('No file provided for resolution search.');
if (mapboxFile) {
const buffer = await mapboxFile.arrayBuffer();
mapboxDataUrl = `data:${mapboxFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}
if (googleFile) {
const buffer = await googleFile.arrayBuffer();
googleDataUrl = `data:${googleFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}

const buffer = await file.arrayBuffer();
const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`;
// Fallback if only 'file' was provided (backward compatibility)
if (!mapboxDataUrl && !googleDataUrl && legacyFile) {
const buffer = await legacyFile.arrayBuffer();
mapboxDataUrl = `data:${legacyFile.type};base64,${Buffer.from(buffer).toString('base64')}`;
}

if (!mapboxDataUrl && !googleDataUrl) {
throw new Error('No files provided for resolution search.');
}

// Get the current messages, excluding tool-related ones.
const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
message =>
message.role !== 'tool' &&
Expand All @@ -69,12 +89,32 @@ async function submit(formData?: FormData, skip?: boolean) {
message.type !== 'resolution_search_result'
);

// The user's prompt for this action is static.
const userInput = 'Analyze this map view.';
const content: CoreMessage['content'] = [
{ type: 'text', text: userInput },
{ type: 'image', image: dataUrl, mimeType: file.type }
];

// Construct the multimodal content for the user message.
const contentParts: any[] = [
{ type: 'text', text: userInput }
]

if (mapboxDataUrl) {
contentParts.push({
type: 'image',
image: mapboxDataUrl,
mimeType: 'image/png'
})
}
if (googleDataUrl) {
contentParts.push({
type: 'image',
image: googleDataUrl,
mimeType: 'image/png'
})
}
Comment on lines +100 to +113
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hardcoded mimeType: 'image/png' ignores the actual file type

The Data URL is created using mapboxFile.type / googleFile.type, but the message parts always send mimeType: 'image/png'. If the browser provides e.g. image/jpeg or image/webp, downstream model/tooling could mis-handle or reject the payload.

This is a runtime correctness issue, not a typing issue.

Suggestion

Preserve the original MIME type alongside each data URL. For example, store mapboxMimeType / googleMimeType when building the data URLs and pass them through into the content parts.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.


const content = contentParts as any

Comment on lines +95 to +116
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

any[] for multimodal content parts hides correctness issues

const contentParts: any[] and const content = contentParts as any bypass the CoreMessage['content'] contract. This is exactly the kind of place where subtle mistakes (wrong mimeType, wrong shape, missing fields) will silently ship and only fail at runtime in a model/tool adapter.

You already used CoreMessage['content'] elsewhere in this file; the resolution-search path should do the same.

Suggestion

Type the parts as CoreMessage['content'] (or the specific multimodal union type your ai SDK expects) and avoid any.

Example direction:

  • const content: CoreMessage['content'] = [{ type: 'text', text: userInput }, ...images]
  • Build images as a properly typed array of image parts.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

// Add the new user message to the AI state.
aiState.update({
...aiState.get(),
messages: [
Expand All @@ -84,11 +124,13 @@ async function submit(formData?: FormData, skip?: boolean) {
});
messages.push({ role: 'user', content });

// Create a streamable value for the summary.
const summaryStream = createStreamableValue<string>('');
const groupeId = nanoid();

async function processResolutionSearch() {
try {
// Call the simplified agent, which now returns a stream.
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures);

let fullSummary = '';
Expand All @@ -100,6 +142,8 @@ async function submit(formData?: FormData, skip?: boolean) {
}

const analysisResult = await streamResult.object;

// Mark the summary stream as done with the result.
summaryStream.done(analysisResult.summary || 'Analysis complete.');

if (analysisResult.geoJson) {
Expand Down Expand Up @@ -147,7 +191,7 @@ async function submit(formData?: FormData, skip?: boolean) {
role: 'assistant',
content: JSON.stringify({
...analysisResult,
image: dataUrl
image: JSON.stringify({ mapbox: mapboxDataUrl, google: googleDataUrl })
}),
type: 'resolution_search_result'
},
Comment on lines 191 to 197
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolution search stores image as a JSON string inside an already JSON-stringified message

You’re doing content: JSON.stringify({ ...analysisResult, image: JSON.stringify({ mapbox, google }) }). That means the image field is double-encoded, forcing consumers to JSON.parse twice and complicating backward compatibility.

This increases brittleness and makes migrations harder (you already had to add a try/catch parsing branch).

Suggestion

Store image as an object directly in the assistant message payload: image: { mapbox: mapboxDataUrl, google: googleDataUrl }. Then adjust the UI-state parser to accept either an object (new) or string (old).

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Expand All @@ -174,11 +218,13 @@ async function submit(formData?: FormData, skip?: boolean) {
}
}

// Start the background process without awaiting it.
processResolutionSearch();

// Immediately update the UI stream with the BotMessage component.
uiStream.update(
<Section title="response">
<ResolutionImage src={dataUrl} />
<ResolutionImage mapboxSrc={mapboxDataUrl} googleSrc={googleDataUrl} />
<BotMessage content={summaryStream.value} />
</Section>
);
Expand Down Expand Up @@ -243,6 +289,7 @@ async function submit(formData?: FormData, skip?: boolean) {

uiStream.append(answerSection);

const groupeId = nanoid();
const relatedQueries = { items: [] };

aiState.done({
Expand Down Expand Up @@ -326,6 +373,7 @@ async function submit(formData?: FormData, skip?: boolean) {
}

const hasImage = messageParts.some(part => part.type === 'image')
// Properly type the content based on whether it contains images
const content: CoreMessage['content'] = hasImage
? messageParts as CoreMessage['content']
: messageParts.map(part => part.text).join('\n')
Expand Down Expand Up @@ -359,6 +407,7 @@ async function submit(formData?: FormData, skip?: boolean) {

const userId = 'anonymous'
const currentSystemPrompt = (await getSystemPrompt(userId)) || ''

const mapProvider = formData?.get('mapProvider') as 'mapbox' | 'google'

async function processEvents() {
Expand Down Expand Up @@ -407,8 +456,7 @@ async function submit(formData?: FormData, skip?: boolean) {
streamText,
messages,
mapProvider,
useSpecificAPI,
drawnFeatures
useSpecificAPI
)
answer = fullResponse
toolOutputs = toolResponses
Expand Down Expand Up @@ -641,10 +689,12 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
case 'input_related':
let messageContent: string | any[]
try {
// For backward compatibility with old messages that stored a JSON string
const json = JSON.parse(content as string)
messageContent =
type === 'input' ? json.input : json.related_query
} catch (e) {
// New messages will store the content array or string directly
messageContent = content
}
return {
Expand All @@ -665,8 +715,8 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
}
break
case 'assistant':
const answer = createStreamableValue(content as string)
answer.done(content as string)
const answer = createStreamableValue()
answer.done(content)
switch (type) {
case 'response':
return {
Expand All @@ -678,9 +728,7 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
)
}
case 'related':
const relatedQueries = createStreamableValue<RelatedQueries>({
items: []
})
const relatedQueries = createStreamableValue<RelatedQueries>()
relatedQueries.done(JSON.parse(content as string))
return {
id,
Expand All @@ -702,13 +750,28 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
case 'resolution_search_result': {
const analysisResult = JSON.parse(content as string);
const geoJson = analysisResult.geoJson as FeatureCollection;
const image = analysisResult.image as string;
const imageData = analysisResult.image as string;
let mapboxSrc = '';
let googleSrc = '';

if (imageData) {
try {
const parsed = JSON.parse(imageData);
mapboxSrc = parsed.mapbox || '';
googleSrc = parsed.google || '';
} catch (e) {
// Fallback for older image format which was just a single string
mapboxSrc = imageData;
}
}

return {
id,
component: (
<>
{image && <ResolutionImage src={image} />}
{(mapboxSrc || googleSrc) && (
<ResolutionImage mapboxSrc={mapboxSrc} googleSrc={googleSrc} />
)}
{geoJson && (
<GeoJsonLayer id={id} data={geoJson} />
)}
Expand All @@ -721,7 +784,7 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
case 'tool':
try {
const toolOutput = JSON.parse(content as string)
const isCollapsed = createStreamableValue(true)
const isCollapsed = createStreamableValue()
isCollapsed.done(true)

if (
Expand Down Expand Up @@ -749,9 +812,7 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
}
}

const searchResults = createStreamableValue(
JSON.stringify(toolOutput)
)
const searchResults = createStreamableValue()
searchResults.done(JSON.stringify(toolOutput))
switch (name) {
case 'search':
Expand Down
52 changes: 22 additions & 30 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ import { SpeedInsights } from "@vercel/speed-insights/next"
import { Toaster } from '@/components/ui/sonner'
import { MapToggleProvider } from '@/components/map-toggle-context'
import { ProfileToggleProvider } from '@/components/profile-toggle-context'
import { UsageToggleProvider } from '@/components/usage-toggle-context'
import { CalendarToggleProvider } from '@/components/calendar-toggle-context'
import { HistoryToggleProvider } from '@/components/history-toggle-context'
import { HistorySidebar } from '@/components/history-sidebar'
import { MapLoadingProvider } from '@/components/map-loading-context';
import ConditionalLottie from '@/components/conditional-lottie';
import { MapProvider as MapContextProvider } from '@/components/map/map-context'
Expand Down Expand Up @@ -73,33 +70,28 @@ export default function RootLayout({
)}
>
<CalendarToggleProvider>
<HistoryToggleProvider>
<MapToggleProvider>
<ProfileToggleProvider>
<UsageToggleProvider>
<ThemeProvider
attribute="class"
defaultTheme="earth"
enableSystem
disableTransitionOnChange
themes={['light', 'dark', 'earth']}
>
<MapContextProvider>
<MapLoadingProvider>
<Header />
<ConditionalLottie />
{children}
<Sidebar />
<HistorySidebar />
<Footer />
<Toaster />
</MapLoadingProvider>
</MapContextProvider>
</ThemeProvider>
</UsageToggleProvider>
</ProfileToggleProvider>
</MapToggleProvider>
</HistoryToggleProvider>
<MapToggleProvider>
<ProfileToggleProvider>
<ThemeProvider
attribute="class"
defaultTheme="earth"
enableSystem
disableTransitionOnChange
themes={['light', 'dark', 'earth']}
>
<MapContextProvider>
<MapLoadingProvider>
<Header />
<ConditionalLottie />
{children}
<Sidebar />
<Footer />
<Toaster />
</MapLoadingProvider>
</MapContextProvider>
</ThemeProvider>
</ProfileToggleProvider>
</MapToggleProvider>
</CalendarToggleProvider>
<Analytics />
<SpeedInsights />
Expand Down
8 changes: 4 additions & 4 deletions components/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useState, useRef, ChangeEvent, forwardRef, useImperativeHandle, useCallback } from 'react'
import type { AI, UIState } from '@/app/actions'
import { useUIState, useActions, readStreamableValue } from 'ai/rsc'
// Removed import of useGeospatialToolMcp as it's no longer used/available
import { cn } from '@/lib/utils'
import { UserMessage } from './user-message'
import { Button } from './ui/button'
Expand Down Expand Up @@ -30,6 +31,7 @@ export interface ChatPanelRef {
export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, input, setInput, onSuggestionsChange }, ref) => {
const [, setMessages] = useUIState<typeof AI>()
const { submit, clearChat } = useActions()
// Removed mcp instance as it's no longer passed to submit
const { mapProvider } = useSettingsStore()
const [isMobile, setIsMobile] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
Expand Down Expand Up @@ -115,9 +117,6 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
formData.append('file', selectedFile)
}

// Include drawn features in the form data
formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || []))

setInput('')
clearAttachment()

Expand Down Expand Up @@ -154,7 +153,7 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
}
}, 500) // 500ms debounce delay
},
[mapData, setSuggestions]
[mapData]
)

useEffect(() => {
Expand Down Expand Up @@ -289,6 +288,7 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
>
<ArrowRight size={isMobile ? 18 : 20} />
</Button>
{/* Suggestions are now handled by the parent component (chat.tsx) as an overlay */}
</div>
</form>
{selectedFile && (
Expand Down
Loading