diff --git a/CC BY-NC 4.0.docx b/CC BY-NC 4.0.docx new file mode 100644 index 00000000..0d8f7d55 Binary files /dev/null and b/CC BY-NC 4.0.docx differ diff --git a/app/actions.tsx b/app/actions.tsx index 9e0ee20a..31bba0bb 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -23,6 +23,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 { MapDataUpdater } from '@/components/map/map-data-updater' import { CopilotDisplay } from '@/components/copilot-display' import RetrieveSection from '@/components/retrieve-section' import { VideoSearchSection } from '@/components/video-search-section' @@ -210,6 +211,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( + + ) + } + } + } 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)` @@ -301,6 +333,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 }) } @@ -315,8 +349,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' + } + ] + }) + + // Immediately append the updater to the UI stream + uiStream.append( + + ) + } + } 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}` @@ -624,10 +689,19 @@ export const AI = createAI({ 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 + } + if ( !type || type === 'end' || @@ -669,8 +743,8 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { } break case 'assistant': - const answer = createStreamableValue() - answer.done(content) + const answer = createStreamableValue(content as string) + answer.done(content as string) switch (type) { case 'response': return { @@ -682,7 +756,9 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { ) } case 'related': - const relatedQueries = createStreamableValue() + const relatedQueries = createStreamableValue({ + items: [] + }) relatedQueries.done(JSON.parse(content as string)) return { id, @@ -716,12 +792,19 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { ) } } + case 'geojson_upload': { + const { data, filename } = JSON.parse(content as string) + return { + id, + component: + } + } } break case 'tool': try { const toolOutput = JSON.parse(content as string) - const isCollapsed = createStreamableValue() + const isCollapsed = createStreamableValue(true) isCollapsed.done(true) if ( @@ -735,7 +818,9 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { } } - const searchResults = createStreamableValue() + const searchResults = createStreamableValue( + JSON.stringify(toolOutput) + ) searchResults.done(JSON.stringify(toolOutput)) switch (name) { case 'search': @@ -775,6 +860,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) => ( + + ))} + + ) + } + } + return { id, component: null } + } catch (e) { + console.error('Error parsing data message:', e) + return { id, component: null } + } default: return { id, diff --git a/app/layout.tsx b/app/layout.tsx index a092d4fe..bddadc19 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -12,7 +12,10 @@ 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' @@ -70,28 +73,33 @@ export default function RootLayout({ )} > - - - - - -
- - {children} - -