diff --git a/app/actions.tsx b/app/actions.tsx index 4f16efcd..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' || @@ -718,6 +792,13 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { ) } } + case 'geojson_upload': { + const { data, filename } = JSON.parse(content as string) + return { + id, + component: + } + } } break case 'tool': @@ -779,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/search/[id]/page.tsx b/app/search/[id]/page.tsx index 8db74186..5523632b 100644 --- a/app/search/[id]/page.tsx +++ b/app/search/[id]/page.tsx @@ -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, }; }); diff --git a/bun.lock b/bun.lock index a3de9819..907bd01f 100644 --- a/bun.lock +++ b/bun.lock @@ -50,7 +50,7 @@ "csv-parse": "^6.1.0", "dotenv": "^16.5.0", "drizzle-kit": "^0.31.1", - "drizzle-orm": "^0.29.0", + "drizzle-orm": "^0.45.1", "embla-carousel-react": "^8.6.0", "exa-js": "^1.6.13", "framer-motion": "^12.23.24", @@ -1313,7 +1313,7 @@ "drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="], - "drizzle-orm": ["drizzle-orm@0.29.5", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=3", "@libsql/client": "*", "@neondatabase/serverless": ">=0.1", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=13.2.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@libsql/client", "@neondatabase/serverless", "@opentelemetry/api", "@planetscale/database", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-jS3+uyzTz4P0Y2CICx8FmRQ1eplURPaIMWDn/yq6k4ShRFj9V7vlJk67lSf2kyYPzQ60GkkNGXcJcwrxZ6QCRw=="], + "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 992f8087..d24077d7 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -214,7 +214,8 @@ export const ChatPanel = forwardRef(({ 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" /> {!isMobile && (