Repository: GeoRetina/chat2geo Branch: main Commit: 8186b6c97b9c Files: 255 Total size: 752.7 KB Directory structure: gitextract_tqshek9r/ ├── .eslintrc.json ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app/ │ ├── (auth)/ │ │ ├── api/ │ │ │ └── auth/ │ │ │ ├── confirm/ │ │ │ │ └── route.ts │ │ │ └── esri/ │ │ │ ├── authorize/ │ │ │ │ └── route.ts │ │ │ └── callback/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── login/ │ │ │ ├── actions.ts │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── styles.css │ ├── (broadcast)/ │ │ ├── api/ │ │ │ └── services/ │ │ │ └── esri/ │ │ │ ├── fetch-layers-list/ │ │ │ │ └── route.ts │ │ │ └── fetch-selected-layer/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── services/ │ │ │ └── esri/ │ │ │ └── fetch-layers/ │ │ │ └── page.tsx │ │ └── styles.css │ ├── (main)/ │ │ ├── actions/ │ │ │ └── get-user-profile.ts │ │ ├── api/ │ │ │ ├── chat/ │ │ │ │ ├── chat-history/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── gee/ │ │ │ │ ├── request-geospatial-analysis/ │ │ │ │ │ └── route.ts │ │ │ │ └── request-loading-geospatial-data/ │ │ │ │ └── route.ts │ │ │ ├── sendfeedback/ │ │ │ │ └── route.ts │ │ │ ├── services/ │ │ │ │ └── google-maps/ │ │ │ │ ├── basemaps/ │ │ │ │ │ ├── roadmap/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── satellite/ │ │ │ │ │ └── route.ts │ │ │ │ ├── geocode/ │ │ │ │ │ └── route.ts │ │ │ │ └── places/ │ │ │ │ └── route.ts │ │ │ ├── user-usage/ │ │ │ │ └── route.ts │ │ │ └── web-scraper/ │ │ │ └── route.ts │ │ ├── chat/ │ │ │ └── [id]/ │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── chat-history/ │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── integrations/ │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── knowledge-base/ │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── styles.css │ └── actions/ │ └── rag-actions.ts ├── components/ │ ├── changelog-modal.tsx │ ├── client-hydrator.tsx │ ├── client-wrapper.tsx │ ├── document-viewer.tsx │ ├── feedback.tsx │ ├── loading-widgets/ │ │ ├── loading-for-widget.tsx │ │ └── loading-primary.tsx │ ├── main-sidebar/ │ │ ├── app-setttings.tsx │ │ └── main-sidebar.tsx │ ├── notices/ │ │ ├── privacy-policy.tsx │ │ └── terms-of-services.tsx │ ├── services/ │ │ └── esri/ │ │ └── add-arcgis-layers.tsx │ ├── theme-provider.tsx │ └── ui/ │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── confirmation-modal.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input-text-confirm.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── table.tsx │ ├── textarea.tsx │ ├── theme-mode-toggle.tsx │ └── tooltip.tsx ├── components.json ├── custom-configs/ │ ├── ai-assistants.ts │ ├── charts-config.ts │ ├── integrations.ts │ └── project-config.ts ├── db-schema/ │ └── schema.sql ├── features/ │ ├── charts/ │ │ ├── components/ │ │ │ ├── charts-display.tsx │ │ │ └── charts.tsx │ │ └── utils/ │ │ └── select-chart-type.ts │ ├── chat/ │ │ ├── components/ │ │ │ ├── artifacts-sidebar/ │ │ │ │ └── artifacts-sidebar.tsx │ │ │ ├── chat-response-box/ │ │ │ │ ├── capabilities-banner.tsx │ │ │ │ ├── chat-message/ │ │ │ │ │ └── chat-message.tsx │ │ │ │ ├── chat-response-box.tsx │ │ │ │ └── in-response-tool-calling-results/ │ │ │ │ ├── display-in-chat-analysis-map/ │ │ │ │ │ └── display-in-chat-analysis-map-btn.tsx │ │ │ │ ├── draft-report/ │ │ │ │ │ ├── draft-report.tsx │ │ │ │ │ └── drafted-report-btn.tsx │ │ │ │ ├── knowledge-base-citation/ │ │ │ │ │ └── citation-badge.tsx │ │ │ │ └── tool-calling-results.tsx │ │ │ ├── chat.tsx │ │ │ ├── external-assets/ │ │ │ │ └── assets-modal.tsx │ │ │ ├── input/ │ │ │ │ ├── chat-input-box.tsx │ │ │ │ ├── chat-input-buttons/ │ │ │ │ │ ├── assistants-list-dropup-in-chat-input.tsx │ │ │ │ │ ├── attachments-list-dropup-in-chat-input.tsx │ │ │ │ │ ├── chat-input-buttons.tsx │ │ │ │ │ ├── current-session-assets-dropup.tsx │ │ │ │ │ ├── open-database-in-chat-input-btn.tsx │ │ │ │ │ └── select-roi-on-map-btn.tsx │ │ │ │ ├── chat-input-dropzone.tsx │ │ │ │ ├── map-tools-dropup.tsx │ │ │ │ └── slash-menu-for-map-layers.tsx │ │ │ ├── text-analysis-suggestions/ │ │ │ │ └── text-analysis-suggestions.tsx │ │ │ └── thumbnails-analysis-suggestions/ │ │ │ └── thumbnails-analysis-suggestions.tsx │ │ ├── hooks/ │ │ │ └── use-slash-menu-map-layers-list.ts │ │ ├── stores/ │ │ │ ├── use-attachments-store.ts │ │ │ ├── use-chat-response-sources-store.ts │ │ │ └── use-drafted-report-store.ts │ │ ├── ui/ │ │ │ └── fadeIn-with-delay.tsx │ │ └── utils/ │ │ ├── drag-and-drop-file-analyzer.ts │ │ ├── general-utils.ts │ │ ├── slash-menu-utils.ts │ │ ├── tool-calling-results-validation.ts │ │ ├── use-Textarea-resize.ts │ │ ├── use-drag-and-drop-file-import.ts │ │ └── use-slash-command-menu.ts │ ├── chat-history/ │ │ └── components/ │ │ ├── chat-history-row.tsx │ │ ├── chat-history-table-skeleton.tsx │ │ ├── chat-history-table.tsx │ │ ├── chat-history.tsx │ │ └── indeterminate-checkbox.tsx │ ├── integrations/ │ │ └── components/ │ │ ├── integration-actions.tsx │ │ ├── integration-header.tsx │ │ ├── integration-item.tsx │ │ ├── integration-list.tsx │ │ ├── integration-status.tsx │ │ └── integrations-page.tsx │ ├── knowledge-base/ │ │ ├── actions/ │ │ │ └── document-actions.ts │ │ ├── components/ │ │ │ ├── add-group-modal.tsx │ │ │ ├── documents-table.tsx │ │ │ ├── edit-document-modal.tsx │ │ │ ├── knolwedge-base.tsx │ │ │ ├── knowledge-base-sidebar.tsx │ │ │ └── max-docs-alert-dialog.tsx │ │ ├── lib/ │ │ │ └── generate-embeddings.ts │ │ └── utils/ │ │ └── transform-metadata-to-citation.ts │ ├── maps/ │ │ ├── components/ │ │ │ ├── address-search.tsx │ │ │ ├── attribute-table/ │ │ │ │ ├── attribute-table-controls.tsx │ │ │ │ └── attribute-table.tsx │ │ │ ├── map-badge.tsx │ │ │ ├── map-container.tsx │ │ │ ├── map-custom-controls/ │ │ │ │ ├── map-custom-controls.tsx │ │ │ │ └── map-roi-controls.tsx │ │ │ └── map-panels/ │ │ │ ├── map-chart-panel/ │ │ │ │ └── map-chart-panel.tsx │ │ │ └── map-layers-panel/ │ │ │ ├── color-picker-popover.tsx │ │ │ ├── map-layers-panel.tsx │ │ │ └── map-legend.tsx │ │ ├── hooks/ │ │ │ ├── use-handle-click/ │ │ │ │ ├── use-handle-click.ts │ │ │ │ ├── use-map-controls.ts │ │ │ │ ├── use-query-drawing.ts │ │ │ │ ├── use-remove-query-features.ts │ │ │ │ └── use-roi-drawing.ts │ │ │ ├── use-map/ │ │ │ │ ├── use-add-arcgis-layers.ts │ │ │ │ ├── use-add-attached-layers.ts │ │ │ │ ├── use-add-gee-layers.ts │ │ │ │ ├── use-add-roi-from-session.ts │ │ │ │ ├── use-basemap-toggle.ts │ │ │ │ ├── use-map-initialization.ts │ │ │ │ ├── use-map.ts │ │ │ │ ├── use-update-layer-style.ts │ │ │ │ └── use-zoom-to-geometry.ts │ │ │ └── use-map-cursor.ts │ │ ├── stores/ │ │ │ ├── map-queries-stores/ │ │ │ │ ├── useQueryOutputReadyFromVectorLayerStore.ts │ │ │ │ ├── useQueryRasterFromVectorLayerStore.ts │ │ │ │ └── useQueryReadyStore.ts │ │ │ ├── plots-stores/ │ │ │ │ ├── useChartRequestedTypeStore.ts │ │ │ │ ├── usePlotReadyDataStore.ts │ │ │ │ └── usePlotReadyFromVectorLayerStore.ts │ │ │ ├── use-agol-layers-store.ts │ │ │ ├── use-color-picker-store.ts │ │ │ ├── use-cursor-store.ts │ │ │ ├── use-drawn-feature-on-map-store.ts │ │ │ ├── use-function-store.ts │ │ │ ├── use-gee-ouput-store.ts │ │ │ ├── use-geojson-store.ts │ │ │ ├── use-layer-selection-store.ts │ │ │ ├── use-map-badge-store.ts │ │ │ ├── use-map-display-store.ts │ │ │ ├── use-map-layer-store.ts │ │ │ ├── use-map-legend-store.ts │ │ │ ├── use-map-zoom-request-store.ts │ │ │ ├── use-roi-store.ts │ │ │ └── use-table-store.ts │ │ └── utils/ │ │ ├── add-drawn-layer-to-map.ts │ │ ├── add-gee-layer-to-map.ts │ │ ├── add-geocoded-point-to-map.ts │ │ ├── add-layers-to-map/ │ │ │ └── addGeojsonLayer.ts │ │ ├── add-roi-layer-to-map.ts │ │ ├── authentication-utils/ │ │ │ └── gee-auth.ts │ │ ├── gee-eval-utils.ts │ │ ├── general-checks.ts │ │ ├── geometry-utils.ts │ │ ├── initialize-map.ts │ │ ├── other-utils.ts │ │ ├── setup-map-attributions.ts │ │ └── type-guards.ts │ ├── text-editor/ │ │ ├── components/ │ │ │ ├── dynamic-text-editor.tsx │ │ │ └── text-editor.tsx │ │ └── schema/ │ │ └── text-editor-schema.tsx │ ├── ui/ │ │ ├── modals/ │ │ │ └── file-upload-modal.tsx │ │ └── toast-message.tsx │ └── user-profile/ │ └── components/ │ └── user-profile-modal.tsx ├── hooks/ │ └── docs-hooks/ │ ├── use-document-upload.ts │ └── use-document-viewer.ts ├── lib/ │ ├── auth.ts │ ├── changelog.ts │ ├── database/ │ │ ├── chat/ │ │ │ ├── queries.ts │ │ │ └── tools.ts │ │ └── usage.ts │ ├── fetchers/ │ │ ├── chat.ts │ │ └── services/ │ │ └── esri/ │ │ ├── fetch-layers-list.ts │ │ └── fetch-selected-layer.ts │ ├── geospatial/ │ │ └── gee/ │ │ ├── analysis-functions/ │ │ │ ├── heat-analysis/ │ │ │ │ └── urban-heat-island-analysis.ts │ │ │ ├── lancover-landuse-mapping/ │ │ │ │ ├── google-dynamic-world-landcover-mapping.ts │ │ │ │ ├── landcover-change-mapping.ts │ │ │ │ └── sentinel-landcover-landuse-mapping.ts │ │ │ └── pollution-analysis/ │ │ │ └── air-pollution-analysis.ts │ │ ├── extract-values-from-gee-layer/ │ │ │ ├── extract-values-from-gee-layer.ts │ │ │ └── geospatial-analyses/ │ │ │ ├── extract-values-from-air-pollution-map.ts │ │ │ ├── extract-values-from-google-dynamic-world-map.ts │ │ │ ├── extract-values-from-landcover-change-map.ts │ │ │ ├── extract-values-from-sentinel-landcover-landuse-map.ts │ │ │ └── extract-values-from-urban-heat-island-map.ts │ │ └── load-data/ │ │ └── load-raster-data.ts │ └── utils.ts ├── middleware.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── stores/ │ ├── use-buttons-store.ts │ ├── use-integration-store.ts │ ├── use-loading-store.ts │ ├── use-sidebar-button-stores.ts │ ├── use-toast-message-store.ts │ └── use-user-profile-store.ts ├── tailwind.config.ts ├── tsconfig.json ├── types/ │ └── global.d.ts └── utils/ ├── general/ │ ├── document-utils.ts │ └── general-utils.ts ├── reset-chat-stores.ts ├── service-handlers/ │ └── esri.ts ├── supabase/ │ ├── client.ts │ ├── middleware.ts │ └── server.ts └── validation-utils/ └── validation-utils.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.json ================================================ { "extends": ["next/core-web-vitals", "next/typescript"], "rules": { "@typescript-eslint/no-unused-vars": "warn", "@typescript-eslint/no-explicit-any": "warn", "react/display-name": "warn", "react-hooks/exhaustive-deps": "warn", "prefer-const": "warn" } } ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # env files (can opt-in for committing if needed) .env* # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts .version ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Chat2Geo Thank you for considering contributing to Chat2Geo! Here are the instructions on how to contribute to this project to ensure consistency throughout the development. ## 1. Fork and Clone 1. Fork this repo (click the "Fork" button on top-right). 2. Clone your fork: ```bash https://github.com/GeoRetina/chat2geo.git ## 3. Pick an issue or be assigned one - You can either pick an issue to work on or be assigned one. - If there is an issue not listed, please create one. ## 4. Create a new branch off of `main` for each issue or feature and name it based on the following pattern ```bash /- where: - : The purpose of the branch. Common types: feat: For new features. fix: For bug fixes. chore: For maintenance or non-functional changes. refactor: For code refactoring. docs: For documentation updates. test: For testing-related changes. - : The issue/feature ID from your issue tracker (e.g., GitHub, Jira). This helps link branches to specific tickets. - : A concise, kebab-case description of the work being done. ``` ## 5. Create a Pull Request (PR) for merging to the main - Merging to the main is only possible by PR and review. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 GeoRetina Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ > [!IMPORTANT] > **This repository is archived and no longer maintained.** > Please use the hosted version at [https://chat2geo.georetina.ai](https://chat2geo.georetina.ai), which is production-ready, more feature-rich, and actively maintained. # Chat2Geo: A ChatGPT-Like Web App for Remote-Sensing-Based Geospatial Analysis Chat2Geo is a Next.js 15 application providing a chatbot-like user interface for performing remote-sensing-based geospatial analyses. It leverages Google Earth Engine (GEE) in the backend to process and analyze various remote sensing datasets in real time. Users can upload their own vector data, run advanced geospatial queries, and integrate the results with an AI Assistant for specialized tasks such as **land cover mapping**, **change detection**, and **air pollutant monitoring**. Chat2Geo also has advanced knowledge retrieval based on Retrieval-augmented generation (RAG), which can integrate geospatial analysis with non-geospatial/textual information. The app also has authentication and database integrations, making it almost a complete package. Chat2Geo inherits a large portion of its building blocks from the GRAI 2.0 app that is under development at GeoRetina (www.georetina.com). In parallel with GRAI 2.0 (which will be merged to Chat2Geo once it's stable), we will also keep Chat2Geo updated for the community. ### 🌍 Try Chat2Geo: https://chat2geo.georetina.ai ---- https://github.com/user-attachments/assets/d9940a0e-10c8-4d0e-9ec9-3dfd0966c664 ## Contributing 🛠️ - If you're interested in contributing to this project, please contact us at `shahabj.github@gmail.com`. - If you are a new contributor, please first check out our [Contributing Guidelines](./CONTRIBUTING.md) to get started. ## Table of Contents - [Features](#features-) - [Tech Stack](#tech-stack-) - [Getting Started](#getting-started-) - [Current Analyses](#available-geospatial-analyses-) - [Considerations](#considerations) --- ## Features ✨ 1. **Chat-Style Interface** - Interact with the system using natural language. - The AI Assistant can execute various **geospatial functions** on your behalf. 2. **Google Earth Engine Integration** - Real-time access to satellite imagery and remote sensing datasets. - Seamless backend processing for large-scale geospatial computations. 3. **Import Your Own Vector Data** - Upload and manage personal vector layers. - Integrate your data with Earth Engine operations for advanced queries. 4. **Analysis Toolkit** - **Air Pollutants** - **Urban Heat Island (UHI)** metrics - **Land Cover** mapping & **Change Detection** - Custom AI models deployed on **Vertex AI** for certain land cover tasks 5. **RAG & Knowledge Base** - Enables a Retrieval-Augmented Generation (RAG) workflow. - Upload documents to build a local knowledge base. - The AI Assistant can then combine geospatial insights with custom document knowledge. ## Tech Stack 💻 - Next.js - Google Cloud Platform (GCP): - Google Earth Engine (remote-sensing data invocation and processing) - Vertex AI (custom AI vision models) - Cloud Run - Vercel AI - OpenAI (ChatGPT API) - Supabase (database and authentication) - LangChain (RAG) - Turf (for spatial operations) - Maplibre GL (for displaying maps) ## Getting Started 🚀 1. Clone the repo 2. Install dependencies ```bash npm install ``` 3. Create a Google Earth Engine (GEE) account and project, otherwise no analysis can be done. Note that GEE is currently only free for non-commercial use: - https://earthengine.google.com 5. Set up the environment variables - Create a `.env.local` file (or similar) with the required credentials for: - Your base url: ``` BASE_URL=http://localhost:3000 # Change it if you're using a different port. In production, you should set it to the url of the deployment. ``` - Google Cloud Platform (GCP): ``` GOOGLE_MAPS_API_KEY= # API key for Google maps. You can replace Google Maps with OSM if you want. VERTEXTAI_ENDPOINT_BASE_URL= # Base URL if you use your own custom models. GEE_CLOUD_RUN_URL= # URL for invoking a model hosted on VertexAI using cloud functions. GCP_BUCKET_NAME= # Bucket name to store the land-cover map generated by your custom model (if applicable). GCP_SERVICE_ACCOUNT_KEY= # Service Account key needed for GEE functions, depending on your GCP configurations. Make sure it has all the required permissions to use GEE. ``` - Large Language Model (LLM) API Key: ``` OPENAI_API_KEY= # It shouldn't be necessarily OpenAI, thought. You can change it to any other API supported by Vercel AI SDK. However, you need to make some changes to the Chat API route. ``` - For the database & authentication, the app uses Supabase. So you need the Supabase API keys as well: NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= - If you want Esri integration, you also need the following keys in your env (skip this part if you don't want this integration): ARCGIS_CLIENT_ID= ARCGIS_CLIENT_SECRET= ARCGIS_REDIRECT_URI= - For feedback submission, I just used a simple email-based pipeline based on Mailgun (skip this part if you don't want this feature): MAILGUN_API_KEY= MAILGUN_DOMAIN= RECIPIENT_EMAIL= SENDER_EMAIL= 5. Run the develpment server npm run dev Visit http://localhost:3000 to view the application. ## How to Set up Supabase Database, Storage Bucket, & Authentication 🛢️ Supabase has a free-tier, generous plan that you can use to work with the app. As mentioned above, the database (PostgreSQL) and authentication are both hosted on Supabase. To set up them, you can either use the local dev (https://supabase.com/docs/guides/local-development/cli/getting-started) or online (https://supabase.com/docs/guides/database/overview). To set up the database and Supabase auth online, you need to create a supabase project & create the required databases and auth. You can find the database schema of the app in the `db-schema` folder. If you want to also use the Knowledge Base feature, you need to create a storage bucket on Supabase as well. The name of the bucket should be `documents_bucket`. This is where the PDF docs you upload to the Knowledge Base are stored. You can set up the bucket by going to the following link: - https://supabase.com/dashboard/project/_/storage/buckets ## Available Geospatial Analyses 📊 The app includes the following geospatial analyses: | # | Analysis Type | Description | |----|------------------------------------------------|-------------| | 1 | **Urban Heat Island (UHI) Analysis** | Evaluates temperature variations in urban areas compared to rural surroundings. | | 2 | **Land-Use/Land-Cover Mapping** | Uses Google DynamicWorld to classify land cover types. | | 3 | **Land-Use/Land-Cover Change Mapping** | Detects changes in land use over time using Google DynamicWorld. | | 4 | **Air Pollution Analysis** *(Not fully implemented)* | Analyzes air pollution patterns and trends. | ## Considerations💡 - Note that all remote-sensing geospatial analyses, at least for now, are based on GEE in this app. So, if you don't set up your GEE environment correctly, no analysis can be done. - It should be noted that this app is not yet ready for production. The app has known bugs, and perhaps unknown ones 😁 Some functionalities have not been implemented yet. - I may have forgotten to include some steps in setting up the app! 😅 If there's missing information in the instructions, please open an issue and let me know to update the instructions accordingly. - GEE-based geospatial analyses are just simple examples of how such analyses can be implemented and added. Some of them are using data that may not be up-to-date. As a result, care should be taken while interpreting the results. - There are parts that should be refactored or re-designed either because they could have been used/invoked in a better place, or because they should've been implemented in a much better manner. ## Frequently Asked Questions (FAQ) 📌
🔹 General Questions **❓ Is this project free to use?** *Yes! This open-source version is free to use under the terms of its license. However, note that Google Earth Engine has restrictions on commercial usage.*
🔹 Support & Contributions **❓ How can I get support for issues?** - *If you encounter a bug or have a feature request, please [open an issue](../../issues) on GitHub.* - *Be as detailed as possible when describing your issue (include screenshots, step-by-step explanations, error logs, and any relevant details). Abstract or vague questions will not be answered.* - *For other questions, feel free to reach out at [shahabj.github@gmail.com](mailto:shahabj.github@gmail.com).* **❓ How can I contribute?** *We welcome contributions! Please check out the [Contributing Guidelines](./CONTRIBUTING.md) before submitting a pull request or opening an issue. Your help in improving this project is greatly appreciated.*
🔹 Features & Customization **❓ Can I request additional analyses or features?** *Absolutely! You can:* - *Suggest a feature by opening an issue.* - *Fork the repository and implement your own changes.* *For advanced or custom solutions, please see [GRAI 2.0 (Enterprise Version)](#enterprise-version-grai-20) below.* **❓ Can I use my own geospatial datasets?** *Yes! The app allows you to import vector data and integrate it with Google Earth Engine for custom analyses. For raster data, at least for now, you need to either host them on GEE or a GCP bucket.*
🔹 Enterprise Version: GRAI 2.0 **❓ What is GRAI 2.0?** *GRAI 2.0 is the enterprise version of this project, offering:* - *Custom-built solutions tailored to specific client needs.* - *Additional analyses & AI models not included in the open-source version.* - *Continuous updates & premium support.* **❓ How do I get access to GRAI 2.0?** *For enterprise inquiries, please visit the [GeoRetina Contact Page](https://www.georetina.com/contact).*
🔹 Technical & Setup Questions **❓ I'm facing issues with setup. What should I do?** 1. *Check that your environment variables are properly set in `.env.local`.* 2. *To get past the login page, you need to first set up a Supabase Auth as described in [Supabase Setup](#how-to-set-up-supabase-database-storage-bucket--authentication-%EF%B8%8F).* 3. *Check your database configurations.* 4. *Confirm your Google Earth Engine configuration.* 5. *Refer to the [Getting Started](#getting-started) section in this README.* 6. *If issues persist, [open an issue](../../issues).*
*Have a question not listed here? Feel free to [open an issue](../../issues) or reach out via email!* 🚀 ================================================ FILE: app/(auth)/api/auth/confirm/route.ts ================================================ import { type EmailOtpType } from "@supabase/supabase-js"; import { type NextRequest, NextResponse } from "next/server"; import { createClient } from "@/utils/supabase/server"; // Creating a handler to a GET request to route /auth/confirm export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const token_hash = searchParams.get("token_hash"); const type = searchParams.get("type") as EmailOtpType | null; const next = "/"; // Create redirect link without the secret token const redirectTo = request.nextUrl.clone(); redirectTo.pathname = next; redirectTo.searchParams.delete("token_hash"); redirectTo.searchParams.delete("type"); if (token_hash && type) { const supabase = await createClient(); const { error } = await supabase.auth.verifyOtp({ type, token_hash, }); if (!error) { redirectTo.searchParams.delete("next"); return NextResponse.redirect(redirectTo); } } // return the user to an error page with some instructions redirectTo.pathname = "/login"; return NextResponse.redirect(redirectTo); } ================================================ FILE: app/(auth)/api/auth/esri/authorize/route.ts ================================================ import { NextResponse } from "next/server"; import { createClient } from "@/utils/supabase/server"; export async function GET() { const supabase = await createClient(); const { data, error } = await supabase.auth.getUser(); if (error || !data.user) { return NextResponse.json({ error: "Unauthenticated!" }, { status: 401 }); } const { ARCGIS_CLIENT_ID, ARCGIS_REDIRECT_URI } = process.env; const authorizationUrl = `https://www.arcgis.com/sharing/rest/oauth2/authorize?client_id=${ARCGIS_CLIENT_ID}&response_type=code&redirect_uri=${encodeURIComponent( ARCGIS_REDIRECT_URI! )}`; return NextResponse.redirect(authorizationUrl); } ================================================ FILE: app/(auth)/api/auth/esri/callback/route.ts ================================================ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/utils/supabase/server"; import cookie from "cookie"; export async function GET(req: NextRequest) { const supabase = await createClient(); const { data, error } = await supabase.auth.getUser(); if (error || !data?.user) { return NextResponse.json({ error: "Unauthenticated!" }, { status: 401 }); } const { searchParams } = new URL(req.url); const code = searchParams.get("code"); if (!code) { return NextResponse.json( { error: "Authorization code is missing" }, { status: 400 } ); } const params = new URLSearchParams(); params.append("client_id", process.env.ARCGIS_CLIENT_ID!); params.append("client_secret", process.env.ARCGIS_CLIENT_SECRET!); params.append("grant_type", "authorization_code"); params.append("code", code); params.append("redirect_uri", process.env.ARCGIS_REDIRECT_URI!); params.append("f", "json"); const baseUrl = process.env.BASE_URL; try { const tokenResponse = await fetch( "https://www.arcgis.com/sharing/rest/oauth2/token", { method: "POST", body: params, } ); const tokenData = await tokenResponse.json(); if (tokenData.error) { throw new Error(tokenData.error.message); } const response = NextResponse.redirect( `${baseUrl}/services/esri/fetch-layers` ); // Store the token securely in an HttpOnly, Secure cookie response.cookies.set("arcgis_access_token", tokenData.access_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict", maxAge: tokenData.expires_in, }); return response; } catch (error) { console.error("Error during OAuth2 callback:", error); return NextResponse.json( { error: "Failed to exchange authorization code for token" }, { status: 500 } ); } } ================================================ FILE: app/(auth)/layout.tsx ================================================ import localFont from "next/font/local"; import "./styles.css"; import { Toaster } from "react-hot-toast"; import ToastMessage from "@/features/ui/toast-message"; import { ThemeProvider } from "@/components/theme-provider"; import { Analytics } from "@vercel/analytics/next"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", variable: "--font-geist-sans", weight: "100 900", }); const geistMono = localFont({ src: "./fonts/GeistMonoVF.woff", variable: "--font-geist-mono", weight: "100 900", }); // Define metadata for the root layout export const metadata = { title: "Login to Chat2Geo", description: "Login to access AI-powered geospatial analytics", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( {children} ); } ================================================ FILE: app/(auth)/login/actions.ts ================================================ "use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { createClient } from "@/utils/supabase/server"; import { cookies } from "next/headers"; import { z } from "zod"; // Define the validation schema using Zod const loginSchema = z.object({ email: z.string().email("Invalid email format."), password: z.string(), }); export async function login(formData: FormData) { const supabase = await createClient(); // Parse and validate the input data const formDataObject = { email: formData.get("email") as string, password: formData.get("password") as string, }; const validation = loginSchema.safeParse(formDataObject); if (!validation.success) { // Return validation errors return { error: validation.error.errors.map((err) => err.message).join(", "), }; } const { email, password } = validation.data; const { data: authData, error: authError } = await supabase.auth.signInWithPassword({ email, password }); if (authError) { return { error: authError.message }; } const { data: userRoles, error: roleError } = await supabase .from("user_roles") .select("name, role, organization, license_start, license_end") .eq("email", email) .single(); if (roleError || !userRoles) { return { error: "User not found or error occurred." }; } const { name, role, organization, license_start, license_end } = userRoles; const licenseStartString = license_start; const licenseEndString = license_end; const currentDate = new Date(); const licenseStartDate = new Date(license_start); const licenseEndDate = new Date(license_end); if (currentDate < licenseStartDate || currentDate > licenseEndDate) { return { error: "Your license has expired or not yet started. Please contact support.", }; } return { success: true, userEmail: email, userName: name, userRole: role, userOrganization: organization, licenseStartString, licenseEndString, }; } export async function logout() { const supabase = await createClient(); await supabase.auth.signOut(); (await cookies()).delete("arcgis_access_token"); revalidatePath("/"); redirect("/login"); } ================================================ FILE: app/(auth)/login/layout.tsx ================================================ export default async function Layout({ children, }: { children: React.ReactNode; }) { return (
{children}
); } ================================================ FILE: app/(auth)/login/page.tsx ================================================ "use client"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState, FormEvent, useTransition } from "react"; import { login } from "@/app/(auth)/login/actions"; import { useUserStore } from "@/stores/use-user-profile-store"; import PrivacyPolicy from "@/components/notices/privacy-policy"; import TermsOfService from "@/components/notices/terms-of-services"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Loader2 } from "lucide-react"; export default function Login() { const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); const [loading, setLoading] = useState(false); const router = useRouter(); const { setUserData } = useUserStore(); const [openTerms, setOpenTerms] = useState(false); const [openPrivacy, setOpenPrivacy] = useState(false); const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setError(null); setLoading(true); const formData = new FormData(event.currentTarget); const result = await login(formData); if (result?.error) { setError(result.error); } else { // Update user store with returned data setUserData( result.userName!, result.userEmail!, result.userRole!, result.userOrganization!, result.licenseStartString!, result.licenseEndString! ); startTransition(() => { router.push("/"); }); } setLoading(false); }; return ( <> {/* Main container for md+ screens */}
{/* Left column with background and testimonial */}

Let powerful AI solutions help you better address various environmental challenges.

{/* Right column with the actual login form */}

Sign in to your account

Enter your credentials below

{/* Email Field */}
{/* Password Field */}
{/* Error Alert */} {error && (

Error: {error}

)} {/* Submit Button */}

By clicking continue, you agree to our{" "} {" "} and{" "} .

); } ================================================ FILE: app/(auth)/styles.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 47.4% 11.2%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --card: 0 0% 100%; --card-foreground: 222.2 47.4% 11.2%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --primary-blue: 220 90% 56%; --primary-blue-foreground: 210 40% 98%; --primary-green: 145 63% 42%; --primary-green-foreground: 210 40% 98%; --secondary: 220 13% 92%; /* Neutral light gray */ --secondary-foreground: 220 10% 40%; /* Neutral medium gray */ --accent: 220 15% 85%; /* Neutral soft gray */ --accent-foreground: 220 20% 12%; /* Neutral deep gray */ --destructive: 0 100% 50%; --destructive-foreground: 210 40% 98%; --warning: 45 100% 50%; --warning-foreground: 45 100% 15%; --info: 200 98% 48%; --info-foreground: 210 40% 98%; --ring: 215 20.2% 65.1%; --radius: 0.5rem; } .dark { /* Base */ --background: 240 2% 12%; /* #1f1f21 */ --foreground: 0 0% 95%; /* Very light neutral */ /* Darker variant for cards/popovers */ --card: 220 3% 10%; /* #18191a */ --card-foreground: 0 0% 95%; --popover: 220 3% 10%; /* #18191a */ --popover-foreground: 0 0% 95%; /* Muted */ --muted: 240 2% 16%; /* Slightly lighter than background */ --muted-foreground: 0 0% 60%; /* Subdued text */ /* Interactive elements */ --accent: 240 2% 18%; /* Subtle highlight */ --accent-foreground: 0 0% 98%; /* Borders and rings */ --border: 240 2% 20%; /* Visible but subtle */ --input: 240 2% 20%; --ring: 240 2% 20%; /* Primary */ --primary: 0 0% 98%; /* Almost white */ --primary-foreground: 240 2% 12%; /* Same as background */ /* Secondary */ --secondary: 240 2% 22%; /* Lighter than accent */ --secondary-foreground: 0 0% 98%; /* Semantic colors - coordinated with our neutral theme */ --primary-blue: 220 90% 56%; --primary-blue-foreground: 210 40% 98%; --primary-green: 145 63% 42%; --primary-green-foreground: 210 40% 98%; --destructive: 0 50% 35%; /* More muted red */ --destructive-foreground: 0 0% 98%; --warning: 45 100% 50%; --warning-foreground: 45 100% 15%; --info: 200 98% 48%; --info-foreground: 210 40% 98%; } } @layer base { * { @apply border-border; } body { @apply font-sans antialiased bg-background text-foreground; } } ================================================ FILE: app/(broadcast)/api/services/esri/fetch-layers-list/route.ts ================================================ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/utils/supabase/server"; export async function GET(req: NextRequest) { const supabase = await createClient(); const { data, error } = await supabase.auth.getUser(); if (error || !data?.user) { return NextResponse.json({ error: "Unauthenticated!" }, { status: 401 }); } const accessToken = req.cookies.get("arcgis_access_token")?.value; if (!accessToken) { return NextResponse.json( { error: "No access token found. User needs to authenticate with ArcGIS.", }, { status: 401 } ); } const portalUrl = `https://www.arcgis.com/sharing/rest/portals/self?f=json&token=${accessToken}`; try { const portalResponse = await fetch(portalUrl); const portalData = await portalResponse.json(); if (!portalResponse.ok) { throw new Error("Failed to fetch organization ID"); } const orgId = portalData.id; if (!orgId) { throw new Error("Organization ID not found in portalData"); } const searchUrl = `https://www.arcgis.com/sharing/rest/search?q=orgid:${orgId} (type:"Feature Service")&f=json&token=${accessToken}`; const servicesResponse = await fetch(searchUrl); if (!servicesResponse.ok) { throw new Error( `Failed to fetch services from ArcGIS: ${servicesResponse.statusText}` ); } const servicesData = await servicesResponse.json(); return NextResponse.json(servicesData); } catch (error) { console.error("Error fetching layers or feature services:", error); return NextResponse.json( { error: "Failed to retrieve services or organization information" }, { status: 500 } ); } } ================================================ FILE: app/(broadcast)/api/services/esri/fetch-selected-layer/route.ts ================================================ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/utils/supabase/server"; export async function GET(req: NextRequest) { const supabase = await createClient(); const { data, error } = await supabase.auth.getUser(); if (error || !data?.user) { return NextResponse.json({ error: "Unauthenticated!" }, { status: 401 }); } const token = req.cookies.get("arcgis_access_token")?.value; if (!token) { return NextResponse.json( { error: "Access token is missing" }, { status: 401 } ); } const { searchParams } = new URL(req.url); const layerUrl = searchParams.get("layerUrl"); if (!layerUrl) { return NextResponse.json( { error: "Layer URL is missing" }, { status: 400 } ); } try { const queryUrl = `${layerUrl}/0/query?f=pgeojson&where=1=1`; const response = await fetch(queryUrl, { headers: { Authorization: `Bearer ${token}`, }, }); if (!response.ok) { throw new Error(`Failed to import layer: ${response.statusText}`); } const layerData = await response.json(); return NextResponse.json(layerData); } catch (error) { console.error("Error importing AGOL layer:", error); return NextResponse.json( { error: "Failed to import layer" }, { status: 500 } ); } } ================================================ FILE: app/(broadcast)/layout.tsx ================================================ import localFont from "next/font/local"; import "./styles.css"; import { Toaster } from "react-hot-toast"; import ToastMessage from "@/features/ui/toast-message"; import { ThemeProvider } from "@/components/theme-provider"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", variable: "--font-geist-sans", weight: "100 900", }); const geistMono = localFont({ src: "./fonts/GeistMonoVF.woff", variable: "--font-geist-mono", weight: "100 900", }); // Define metadata for the root layout export const metadata = { title: "Chat2Geo", description: "AI-powered geospatial analytics", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( {children} ); } ================================================ FILE: app/(broadcast)/services/esri/fetch-layers/page.tsx ================================================ import { createClient } from "@/utils/supabase/server"; import { redirect } from "next/navigation"; import AddArcGisLayerClient from "@/components/services/esri/add-arcgis-layers"; export default async function AddArcGisLayerPage() { const supabase = await createClient(); const { data: authResults, error } = await supabase.auth.getUser(); if (error || !authResults?.user) { redirect("/login"); } return ; } ================================================ FILE: app/(broadcast)/styles.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 47.4% 11.2%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --card: 0 0% 100%; --card-foreground: 222.2 47.4% 11.2%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --primary-blue: 220 90% 56%; --primary-blue-foreground: 210 40% 98%; --primary-green: 145 63% 42%; --primary-green-foreground: 210 40% 98%; --secondary: 220 13% 92%; /* Neutral light gray */ --secondary-foreground: 220 10% 40%; /* Neutral medium gray */ --accent: 220 15% 85%; /* Neutral soft gray */ --accent-foreground: 220 20% 12%; /* Neutral deep gray */ --destructive: 0 100% 50%; --destructive-foreground: 210 40% 98%; --warning: 45 100% 50%; --warning-foreground: 45 100% 15%; --info: 200 98% 48%; --info-foreground: 210 40% 98%; --ring: 215 20.2% 65.1%; --radius: 0.5rem; } .dark { /* Base */ --background: 240 2% 12%; /* #1f1f21 */ --foreground: 0 0% 95%; /* Very light neutral */ /* Darker variant for cards/popovers */ --card: 220 3% 10%; /* #18191a */ --card-foreground: 0 0% 95%; --popover: 220 3% 10%; /* #18191a */ --popover-foreground: 0 0% 95%; /* Muted */ --muted: 240 2% 16%; /* Slightly lighter than background */ --muted-foreground: 0 0% 60%; /* Subdued text */ /* Interactive elements */ --accent: 240 2% 18%; /* Subtle highlight */ --accent-foreground: 0 0% 98%; /* Borders and rings */ --border: 240 2% 20%; /* Visible but subtle */ --input: 240 2% 20%; --ring: 240 2% 20%; /* Primary */ --primary: 0 0% 98%; /* Almost white */ --primary-foreground: 240 2% 12%; /* Same as background */ /* Secondary */ --secondary: 240 2% 22%; /* Lighter than accent */ --secondary-foreground: 0 0% 98%; /* Semantic colors - coordinated with our neutral theme */ --primary-blue: 220 90% 56%; --primary-blue-foreground: 210 40% 98%; --primary-green: 145 63% 42%; --primary-green-foreground: 210 40% 98%; --destructive: 0 50% 35%; /* More muted red */ --destructive-foreground: 0 0% 98%; --warning: 45 100% 50%; --warning-foreground: 45 100% 15%; --info: 200 98% 48%; --info-foreground: 210 40% 98%; } } @layer base { * { @apply border-border; } body { @apply font-sans antialiased bg-background text-foreground; } } ================================================ FILE: app/(main)/actions/get-user-profile.ts ================================================ "use server"; import { createClient } from "@/utils/supabase/server"; export async function getUserProfile() { const supabase = await createClient(); const { data: { user }, error: authError, } = await supabase.auth.getUser(); // If no user or no email, return null if (authError || !user || !user.email) { return null; } const { data: userData, error: userError } = await supabase .from("user_roles") .select("name, role, organization, license_start, license_end") .eq("email", user.email) .single(); if (userError || !userData) { return null; } // Now we know user.email is a string return { email: user.email, name: userData.name, role: userData.role, organization: userData.organization, licenseStart: userData.license_start, licenseEnd: userData.license_end, }; } ================================================ FILE: app/(main)/api/chat/chat-history/route.ts ================================================ import { createClient } from "@/utils/supabase/server"; import { getChatsByUser } from "@/lib/database/chat/queries"; import { NextResponse } from "next/server"; interface Chat { id: string; } export async function GET() { const supabase = await createClient(); const { data, error } = await supabase.auth.getUser(); const userId = data.user?.id; if (error || !data?.user) { return NextResponse.json({ error: "Unauthenticated!" }, { status: 401 }); } const chats = (await getChatsByUser(userId as string)) as Chat[]; return NextResponse.json(chats); } ================================================ FILE: app/(main)/api/chat/route.ts ================================================ import { openai } from "@ai-sdk/openai"; import { azure } from "@ai-sdk/azure"; // You can also use Azure's hosted GPT models. More info: https://sdk.vercel.ai/providers/ai-sdk-providers import { type Message, type CoreUserMessage, streamText, convertToCoreMessages, } from "ai"; import { createClient } from "@/utils/supabase/server"; import { z } from "zod"; import { NextResponse } from "next/server"; import { getChatById, saveChat, saveMessages, searchGeeDatasets, } from "@/lib/database/chat/queries"; import { getUsageForUser, getUserRoleAndTier, incrementRequestCount, } from "@/lib/database/usage"; import { getPermissionSet } from "@/lib/auth"; import { requestGeospatialAnalysis, requestLoadingGeospatialData, requestRagQuery, draftReport, requestWebScraping, } from "@/lib/database/chat/tools"; import { generateUUID, sanitizeResponseMessages, getMostRecentUserMessage, generateTitleFromUserMessage, getFormattedDate, } from "@/features/chat/utils/general-utils"; // export const maxDuration = 30; export async function POST(request: Request) { const { id, messages, selectedRoiGeometryInChat, mapLayersNames, }: { id: string; messages: Array; modelId: string; selectedRoiGeometryInChat: any; mapLayersNames: string[]; } = await request.json(); const supabase = await createClient(); const { data: authResult, error: authError } = await supabase.auth.getUser(); if (authError || !authResult?.user) { return NextResponse.json({ error: "Unauthenticated!" }, { status: 401 }); } const userId = authResult.user.id; // Fetch the user's role + subscription const userRoleRecord = await getUserRoleAndTier(userId); if (!userRoleRecord) { return NextResponse.json( { error: "Failed to get role/subscription" }, { status: 403 } ); } const { role, subscription_tier: subscriptionTier } = userRoleRecord; const { maxRequests, maxArea } = await getPermissionSet( role, subscriptionTier ); const usage = await getUsageForUser(userId); if (usage.requests_count >= maxRequests) { return NextResponse.json( { error: "Request limit exceeded" }, { status: 403 } ); } const cookieStore = request.headers.get("cookie"); const chat = await getChatById(id); const coreMessages = convertToCoreMessages(messages); const userMessage = getMostRecentUserMessage(coreMessages); // Increment usage count await incrementRequestCount(userId); if (!chat) { const generatedTitle = await generateTitleFromUserMessage({ message: messages[0] as CoreUserMessage, }); await saveChat({ id: id, title: generatedTitle }); } const userMessageId = generateUUID(); await saveMessages({ messages: [ { ...userMessage, id: userMessageId, createdAt: new Date(), chatId: id, }, ], }); // System instructions const systemInstructions = `Today is ${getFormattedDate()}. You are an AI Assistant specializing in geospatial analytics. Be kind, warm, and professional. Use emojis where appropriate to enhance user experience. When user asks for a geospatial analysis or data, never ask for the location unless you run the analysis and you get a corresponding error. Users provide the name of their region of interest (ROI) data when requesting an analysis. Always highlight important outputs and provide help in interpreting results. NEVER include map URLs or map legends/palette (like classes) in your responses. Refuse to answer questions irrelevant to geospatial analytics or the platform's context. You have access to several tools. If running a tool fails, and you thought you would be to fix it with a change, try 3 times until you fix it. IF USER ASKS FOR DRAFTING REPORTS, YOU SHOULD RUN THE "draftReport" TOOL, AND JUST CONFIRM THE DRAFTING OF THE REPORT. YOU SHOULD NOT EVER DRAFT REPORT IN THE CHAT." You also have access to a tool that can load geospatial data. First, run the tool that searches the database containing GEE datasets information to find the datasets best match user's request. Afterwards, run the web scraper tool to find extra info such as how to set the visualization parameter (pay attention to the code snippet from the official doc you will recieve). After that provide a short summary of what data with what parameters you're going to load to make sure if it's exactly what the user needs. After everything goes well and the user confirmed the details of the analysis to run, use all the information to load the dataset. Another tool you have access to is a RAG query tool that you can use to answer questions you don't know the answer to. Before running any geospatial analysis, make sure the layer name doesn't already exist in the map layers. No geospatial analysis is available for the year 2025, so you SHOULD NOT run analysis for 2025 even if the user asks for it. When executing analyes (not ragQueryRetrieval, though): 1. Always provide a clear summary of what was analyzed 2. Highlight key findings and patterns in the data, 3. Try to tabulate some parts of the results/descriptions for the sake of clarity.`; // Prepend system instructions to the conversation as a separate message for the AI const systemMessage = { role: "assistant", // Change role to "assistant" to avoid unhandled role errors content: systemInstructions, }; // Add the system message at the beginning of the conversation const processedMessages = [ systemMessage, ...messages.filter((msg: any) => msg.role !== "system"), ] as Array; const result = await streamText({ model: openai("gpt-4o"), // model: azure("gpt-4o"), // You can also use Azure's hosted GPT models maxSteps: 5, messages: convertToCoreMessages(processedMessages), onFinish: async ({ response }) => { if (userId) { try { const responseMessagesWithoutIncompleteToolCalls = sanitizeResponseMessages(response.messages); await saveMessages({ messages: responseMessagesWithoutIncompleteToolCalls.map( (message) => { const messageId = generateUUID(); return { id: messageId, chatId: id, draftedReportId: null, role: message.role, content: message.content, createdAt: new Date(), }; } ), }); } catch (error) { console.error("Failed to save chat"); } } }, tools: { requestGeospatialAnalysis: { description: `Today is ${getFormattedDate()}, so you should be able to help the user with requests by up to this date. No analysis should be done for the year of 2025 as analyses are not yet ready for the new year. After running an analysis: 1. Provide a clear summary of what was analyzed and why, 2. Explain the key findings and their significance. NEVER PROVIDE MAP URLs or MAP LEGENDS FROM THE ANALYSES IN THE RESPONSE. Also the maximum area the user can request analysis for is ${maxArea} sq km. per request. It should be noted that the land cover map (start date: 2015) and bi-temporal land cover change map (start date: 2015) are based on Sentinel-2 imagery, UHI (start date: 2015) is based on Landsat imagery. For all "CHANGE" maps, the user must provide "startDate2 and endDate2". If in doubt about an analysis (e.g., it may not exactly match the analysis we have), you have to double check with the user.`, parameters: z.object({ functionType: z.string() .describe(`The type of analysis to execute. It can be one of the following: 'Urban Heat Island (UHI) Analysis', 'Land Use/Land Cover Maps', 'Land Use/Land Cover Change Maps'.`), startDate1String: z .string() .describe( "The start date for the first period. The date format should be 'YYYY-MM-DD'. But convert any other date format the user gives you to that one." ), endDate1String: z .string() .describe( "The end date for the first period. The date format should be 'YYYY-MM-DD'. But convert any other date format the user gives you to that one." ), startDate2String: z .string() .optional() .describe( "The start date for the second period. The date format should be 'YYYY-MM-DD'. But convert any other date format the user gives you to that one." ), endDate2String: z .string() .optional() .describe( "The end date for the second period. The date format should be 'YYYY-MM-DD'. But convert any other date format the user gives you to that one." ), aggregationMethod: z.string().describe( `The method to use for aggregating the data. It means that in a time-series, what method is used to aggregate data for a given point/pixel in the final map/analysis delivered. For land use/land cover mapping, it's always "Median", and thus you don't need to ask user for that. It can be one of the following: 'Mean', 'Median', 'Min', 'Max', . Note that the user may not provide it, so by default its value should be 'Max', and you should not ask the user to tell you what method to use. If the default value is used, make sure to mention it in the response to user that your analysis is based on the maximum va. ` ), layerName: z .string() .describe( "The name of the layer to be displayed. You ask the user about it if they don't provide it. Otherwise, use a name based on the function type, but make sure the name is concise and descriptive. " ), title: z .string() .optional() .describe( "Briefly describe the title of the analysis in one sentence confirming you're working on the user's request." ), }), execute: async (args) => requestGeospatialAnalysis({ ...args, cookieStore, selectedRoiGeometryInChat, maxArea, }), }, requestLoadingGeospatialData: { description: `The user has requested loading and visualizing geospatial data. You should load the data based on the user's request.`, parameters: z.object({ geospatialDataType: z.string().describe( `The type of geospatial data to load. It can be one of the following: 'Load GEE Data'` ), selectedRoiGeometry: z .object({ type: z.string().optional(), coordinates: z.array(z.array(z.array(z.number()))).optional(), }) .optional() .describe( "The selected region of interest (ROI) geometry. You should run the analysis based on the user's request." ), dataType: z .string() .describe( `The type of data to load. It can be one of the following: 'Image', 'ImageCollection'.` ), divideValue: z .number() .describe( `The value to divide the image by. If based on the scraped data you didn't find it, use your logic to see if it should be set based on the dataset. Sometimes, the division is done within a "cloud mask" function, so you should extract its value from there in that case. If you decide not to set it, set it to 1.` ), datasetId: z.string().describe("The ID of the GEE dataset to load."), startDate: z .string() .describe( "The start date for the data to load. The date format should be 'YYYY-MM-DD'. But convert any other date format the user gives you to that one." ), endDate: z .string() .describe( "The end date for the data to load. The date format should be 'YYYY-MM-DD'. But convert any other date format the user gives you to that one." ), visParams: z.union([ // single-band case z.object({ bands: z.array(z.string()).length(1), palette: z.array(z.string()), min: z.number().optional(), max: z.number().optional(), }), // multi-band case z.object({ bands: z.array(z.string()), min: z.number().optional(), max: z.number().optional(), }), ]) .describe(`You should set the visualization parameters best matching user's request for the data to load and best way of visualization: 1) If you want to combine more than one band for visualization, set visParams using the bands: [...] attribute. 2) Otherwise, use the palette: [...] attribute (and do not include bands). As an example, RGB visualization should be set as: {bands: ['red', 'green', 'blue']}. Forest loss should be using pellete if it's one band. `), labelNames: z .array(z.string()) .describe( "The label names for the data to load. You should run the analysis based on the user's request. Choose the closet label names even if it doesn't 100% match what you already know. Infer it." ), layerName: z .string() .describe( "The name of the layer to be displayed. You ask the user about it if they don't provide it. Otherwise, use a name based on the function type, but make sure the name is concise and descriptive. " ), title: z .string() .optional() .describe( "Briefly describe the title of the analysis in one sentence confirming you're working on the user's request." ), }), execute: async (args) => { return requestLoadingGeospatialData({ ...args, cookieStore, selectedRoiGeometryInChat, }); }, }, searchGeeDatasets: { description: `Find the datasets available in Google Earth Engine (GEE) that best match the user's query.`, parameters: z.object({ query: z.string().describe("The name of the dataset to search."), startDate: z .string() .optional() .describe( "The start date for the data to load based on the scraping results. This could be the year or the date in a format. This shows the start date the data is available." ), endDate: z .string() .optional() .describe( "The end date for the data to load based on the scraping result. This could be the year or the date in a format. This shows the end date the data is available." ), title: z .string() .optional() .describe( "Briefly describe the title of the analysis in one sentence confirming you're working on the user's request." ), }), execute: async (args) => { const result = searchGeeDatasets(args.query); return result; }, }, scrapeWebpage: { description: "Scrape the webpage of the GEE dataset to learn what dataset_id, how data is visualized, legends, any division by a value, etc. you should use for the the requested dataset. For example, one of the things you should learn is whether you need to have a band combination (e.g., [b1, b2, b3]) or a palette (e.g., ['red', 'green', 'blue']) to visualize the image.", parameters: z.object({ url: z .string() .describe( "The asset URL of the webpage to scrape. The name of the column you're scraping for this parameter should be 'asset_url'." ), title: z .string() .optional() .describe( "Briefly describe the title of the analysis in one sentence confirming you're working on the user's request." ), }), execute: async (args) => { return requestWebScraping(args); }, }, requestRagQuery: { description: `The user has some documents with which a RAG has been built. If you're asked a question that you didn't know the answer, run the requestRagQuery tool that is based on user's documents to get the answer.`, parameters: z.object({ query: z.string().describe("The user's query text."), title: z .string() .optional() .describe( "Briefly describe the title of the analysis in one sentence confirming you're working on the user's request." ), }), execute: async (args) => requestRagQuery({ ...args, cookieStore }), }, draftReport: { description: `When this tool is called, draft a report that summarizes the analyses and their results. The report should be concise and easy to understand, highlighting the key findings and insights. Markdown is supported.`, parameters: z.object({ messages: z .array(z.object({})) .describe( "The messages exchanged between the user and the you. You should use relevant messages in the chat to generate the report the user requested. Make sure you format the report in a standard way with all the common structures." ), title: z .string() .optional() .describe( "Briefly describe the title of the report to be drafted in one sentence confirming you're working on the user's request." ), reportFileName: z .string() .optional() .describe("Provide a concise name for the report file."), }), execute: async (args) => draftReport({ ...args, messages: processedMessages }), }, checkMapLayersNames: { description: "Here are the the names of the current map layers. If you run a geospatial analysis, and you select a name for the layer, you should should first check the layer names to make sure the name you selected is not already in use. You shouldn't output any message regarding the name you select.", parameters: z.object({ layerName: z .string() .describe("The name of the layer to be displayed."), title: z .string() .optional() .describe( "Briefly describe the title of the analysis in one sentence confirming you're working on the user's request." ), }), execute: async (args) => { return mapLayersNames; }, }, }, }); return result.toDataStreamResponse(); } ================================================ FILE: app/(main)/api/gee/request-geospatial-analysis/route.ts ================================================ import { createClient } from "@/utils/supabase/server"; import { NextResponse } from "next/server"; import { NextRequest } from "next/server"; import { geeAuthenticate } from "@/features/maps/utils/authentication-utils/gee-auth"; import { urbanHeatIslandAnalysis } from "@/lib/geospatial/gee/analysis-functions/heat-analysis/urban-heat-island-analysis"; import { airPollutionAnalysis } from "@/lib/geospatial/gee/analysis-functions/pollution-analysis/air-pollution-analysis"; import { sentinelLandcoverLanduseMapping } from "@/lib/geospatial/gee/analysis-functions/lancover-landuse-mapping/sentinel-landcover-landuse-mapping"; import { convertToEeGeometry } from "@/features/maps/utils/geometry-utils"; import googleDynamicWorldMapping from "@/lib/geospatial/gee/analysis-functions/lancover-landuse-mapping/google-dynamic-world-landcover-mapping"; import landcoverChangeMapping from "@/lib/geospatial/gee/analysis-functions/lancover-landuse-mapping/landcover-change-mapping"; const validAnalysisOptionsForVulnerabilityMapBuilder: MultiAnalysisOptionsTypeForVulnerabilityMapBuilderType[] = ["Air Pollutants", "Flood Risk", "Urban Heat Island (UHI)"]; const validAnalysisOptionsForAtmosphericGasAnalysis: MultiAnalysisOptionsTypeForAirPollutantsAnalysisType[] = ["CO", "NO2", "CH4", "Aerosols"]; export async function POST(req: NextRequest) { const supabase = await createClient(); const { data, error } = await supabase.auth.getUser(); if (error || !data?.user) { return NextResponse.json({ error: "Unauthenticated!" }, { status: 401 }); } // Parse the request body as JSON let body; try { body = await req.json(); } catch (err) { console.error(err); return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } const { functionType, selectedRoiGeometry, aggregationMethod, startDate1, endDate1, startDate2, endDate2, multiAnalysisOptions, } = body; // Validate the ROI geometry if (!selectedRoiGeometry) { return NextResponse.json( { error: "No Region of Interest (ROI) was provided. Please provide an ROI.", }, { status: 400 } ); } // If the geometry comes in as a string, decode and parse it let geometry = selectedRoiGeometry; if (typeof selectedRoiGeometry === "string") { try { const geometryString = decodeURIComponent(selectedRoiGeometry); geometry = JSON.parse(geometryString); } catch (err) { return NextResponse.json( { error: "Invalid geometry JSON" }, { status: 400 } ); } } try { await initializeGee(); const eeReadyGeometry = convertToEeGeometry(geometry); let result; switch (functionType) { case "Air Pollution Analysis": if ( multiAnalysisOptions.every((option: any) => validAnalysisOptionsForAtmosphericGasAnalysis.includes( option as MultiAnalysisOptionsTypeForAirPollutantsAnalysisType ) ) ) { result = await airPollutionAnalysis( geometry, multiAnalysisOptions as MultiAnalysisOptionsTypeForAirPollutantsAnalysisType[], startDate1, endDate1, aggregationMethod as AggregationMethodTypeNumerical, startDate2, endDate2 ); } else { throw new Error( "Invalid analysis option for Air Pollutants Analysis" ); } break; case "Land Use/Land Cover Maps": result = await googleDynamicWorldMapping( eeReadyGeometry, startDate1, endDate1 ); break; case "Land Use/Land Cover Change Maps": result = await landcoverChangeMapping( eeReadyGeometry, startDate1, endDate1, startDate2, endDate2 ); break; case "Urban Heat Island (UHI) Analysis": result = await urbanHeatIslandAnalysis( eeReadyGeometry, startDate1, endDate1, aggregationMethod as AggregationMethodTypeNumerical ); break; default: throw new Error("Invalid function type"); } return NextResponse.json(result, { status: 200 }); } catch (error: any) { console.error(error); return NextResponse.json({ result: error.message }, { status: 404 }); } } // Function to initialize Google Earth Engine const initializeGee = async () => { await geeAuthenticate(); }; ================================================ FILE: app/(main)/api/gee/request-loading-geospatial-data/route.ts ================================================ import { createClient } from "@/utils/supabase/server"; import { NextResponse } from "next/server"; import { NextRequest } from "next/server"; import { geeAuthenticate } from "@/features/maps/utils/authentication-utils/gee-auth"; import { convertToEeGeometry } from "@/features/maps/utils/geometry-utils"; import { loadRasterData } from "@/lib/geospatial/gee/load-data/load-raster-data"; export async function POST(req: NextRequest) { const supabase = await createClient(); const { data, error } = await supabase.auth.getUser(); if (error || !data?.user) { return NextResponse.json({ error: "Unauthenticated!" }, { status: 401 }); } let body; try { body = await req.json(); } catch (err) { console.error("Error parsing request body:", err); return NextResponse.json( { error: "Invalid request format" }, { status: 400 } ); } const { geospatialDataType, dataType, selectedRoiGeometry, divideValue, datasetId, startDate, endDate, visParams, labelNames, } = body; if (!selectedRoiGeometry) { return NextResponse.json( { error: "No Region of Interest (ROI) was provided. Please provide an ROI.", }, { status: 400 } ); } let geometry = selectedRoiGeometry; if (typeof selectedRoiGeometry === "string") { try { const geometryString = decodeURIComponent(selectedRoiGeometry); geometry = JSON.parse(geometryString); } catch (err) { return NextResponse.json( { error: "Invalid geometry format" }, { status: 400 } ); } } try { await initializeGee(); const eeReadyGeometry = convertToEeGeometry(geometry); switch (geospatialDataType) { case "Load GEE Data": { if (!datasetId || !startDate || !endDate || !visParams || !labelNames) { return NextResponse.json( { error: "Missing required parameters" }, { status: 400 } ); } const result = await loadRasterData( datasetId, dataType, eeReadyGeometry, startDate, endDate, divideValue, visParams, labelNames ); if (!result) { return NextResponse.json( { error: "Failed to load raster data" }, { status: 500 } ); } return NextResponse.json(result); } default: return NextResponse.json( { error: "Invalid geospatial data type" }, { status: 400 } ); } } catch (error: any) { console.error("Error processing request:", error); return NextResponse.json( { error: error.message || "Failed to process geospatial data" }, { status: 500 } ); } } const initializeGee = async () => { await geeAuthenticate(); }; ================================================ FILE: app/(main)/api/sendfeedback/route.ts ================================================ // It's a simple email-based feedback form. The user sends a message, and it gets sent to a recipient email address. import { createClient } from "@/utils/supabase/server"; import FormData from "form-data"; import Mailgun from "mailgun.js"; import { NextResponse } from "next/server"; const API_KEY = process.env.MAILGUN_API_KEY || ""; const DOMAIN = process.env.MAILGUN_DOMAIN || ""; const ALLOWED_ORIGIN = process.env.NEXT_PUBLIC_APP_URL!; const RECIPIENT_EMAIL = process.env.RECIPIENT_EMAIL; const SENDER_EMAIL = process.env.SENDER_EMAIL; export async function OPTIONS() { return new NextResponse(null, { status: 200, headers: { "Access-Control-Allow-Origin": ALLOWED_ORIGIN, "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }, }); } export async function POST(req: Request) { const supabase = await createClient(); const { data, error } = await supabase.auth.getUser(); const user = data?.user; if (error || !data.user) { return new NextResponse(JSON.stringify({ error: "Unauthenticated!" }), { status: 401, }); } const contentType = req.headers.get("content-type") || ""; if (!contentType.includes("application/json")) { return new NextResponse( JSON.stringify({ error: "Unsupported media type. Please send JSON." }), { status: 415 } ); } let body; try { body = await req.json(); } catch (err) { console.error("Error parsing JSON:", err); return new NextResponse( JSON.stringify({ error: "Invalid JSON payload." }), { status: 400 } ); } const { message } = body || {}; if (!message) { return new NextResponse( JSON.stringify({ error: "All fields (message) are required.", }), { status: 400 } ); } try { const mailgun = new Mailgun(FormData); const client = mailgun.client({ username: "api", key: API_KEY }); const messageData = { from: `Chat2Geo ${SENDER_EMAIL}`, to: RECIPIENT_EMAIL, subject: "New Feedback for Chat2Geo!", text: `Hello, You have a new form entry from: ${user?.email}. ${message} `, }; await client.messages.create(DOMAIN, messageData); return new NextResponse(JSON.stringify({ submitted: true }), { status: 200, }); } catch (mailErr: any) { console.error("Error sending email:", mailErr); return new NextResponse(JSON.stringify({ error: "Failed to send email" }), { status: 500, }); } } ================================================ FILE: app/(main)/api/services/google-maps/basemaps/roadmap/route.ts ================================================ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/utils/supabase/server"; export async function GET(request: NextRequest) { const supabase = await createClient(); const { data, error } = await supabase.auth.getUser(); if (error || !data.user) { return NextResponse.json({ error: "Unauthenticated!" }, { status: 401 }); } const { searchParams } = new URL(request.url); const x = searchParams.get("x"); const y = searchParams.get("y"); const z = searchParams.get("z"); const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY; if (!GOOGLE_MAPS_API_KEY) { return NextResponse.json( { error: "API key is not configured" }, { status: 500 } ); } if (!x || !y || !z) { return NextResponse.json({ error: "Missing parameters" }, { status: 400 }); } const tileUrl = `https://maps.googleapis.com/maps/vt?lyrs=r&x=${x}&y=${y}&z=${z}&key=${GOOGLE_MAPS_API_KEY}`; return NextResponse.redirect(tileUrl); } ================================================ FILE: app/(main)/api/services/google-maps/basemaps/satellite/route.ts ================================================ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/utils/supabase/server"; export async function GET(request: NextRequest) { const supabase = await createClient(); const { data, error } = await supabase.auth.getUser(); if (error || !data.user) { return NextResponse.json({ error: "Unauthenticated!" }, { status: 401 }); } const { searchParams } = new URL(request.url); const x = searchParams.get("x"); const y = searchParams.get("y"); const z = searchParams.get("z"); const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY; if (!GOOGLE_MAPS_API_KEY) { return NextResponse.json( { error: "API key is not configured" }, { status: 500 } ); } if (!x || !y || !z) { return NextResponse.json({ error: "Missing parameters" }, { status: 400 }); } const tileUrl = `https://maps.googleapis.com/maps/vt?lyrs=s&x=${x}&y=${y}&z=${z}&key=${GOOGLE_MAPS_API_KEY}`; return NextResponse.redirect(tileUrl); } ================================================ FILE: app/(main)/api/services/google-maps/geocode/route.ts ================================================ import { NextResponse } from "next/server"; import { Client } from "@googlemaps/google-maps-services-js"; const client = new Client({}); export async function POST(request: Request) { const { address } = await request.json(); if (!address) { return NextResponse.json({ error: "Address is required" }, { status: 400 }); } try { const response = await client.geocode({ params: { address, key: process.env.GOOGLE_MAPS_API_KEY || "", }, }); return NextResponse.json(response.data); } catch (error) { console.error("Geocoding error:", error); return NextResponse.json( { error: "Failed to geocode address" }, { status: 500 } ); } } ================================================ FILE: app/(main)/api/services/google-maps/places/route.ts ================================================ import { NextResponse } from "next/server"; export async function GET() { const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY; if (!GOOGLE_MAPS_API_KEY) { return NextResponse.json({ error: "API key is missing" }, { status: 500 }); } const scriptUrl = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&libraries=places`; return NextResponse.json({ scriptUrl }); } ================================================ FILE: app/(main)/api/user-usage/route.ts ================================================ import { NextResponse } from "next/server"; import { createClient } from "@/utils/supabase/server"; import { getUsageForUser, getUserRoleAndTier } from "@/lib/database/usage"; import { getPermissionSet } from "@/lib/auth"; export async function GET() { const supabase = await createClient(); const { data: authData, error: authError } = await supabase.auth.getUser(); if (authError || !authData?.user) { return NextResponse.json({ error: "Unauthenticated" }, { status: 401 }); } const userId = authData.user.id; const usage = await getUsageForUser(userId); const userRoleRecord = await getUserRoleAndTier(userId); if (!userRoleRecord) { return NextResponse.json( { error: "Role or subscription not found" }, { status: 404 } ); } const { role, subscription_tier } = userRoleRecord; const { maxRequests, maxDocs, maxArea } = await getPermissionSet( role, subscription_tier ); return NextResponse.json({ requests_count: usage.requests_count, knowledge_base_docs_count: usage.knowledge_base_docs_count, maxRequests, maxDocs, maxArea, }); } ================================================ FILE: app/(main)/api/web-scraper/route.ts ================================================ import { NextResponse } from "next/server"; import * as cheerio from "cheerio"; export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); const url = searchParams.get("url"); if (!url) { return NextResponse.json( { error: "URL parameter is required" }, { status: 400 } ); } const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Cache-Control": "no-cache", Pragma: "no-cache", }, }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const html = await response.text(); const $ = cheerio.load(html); // Initialize content structure focused on code snippets const content = { title: $("title").text().trim(), codeSnippets: [] as Array<{ language?: string; code: string; context?: string; // Optional heading or context where the code was found }>, }; // Look for code blocks in pre and code tags $("pre, code").each((_, element) => { const $el = $(element); const code = $el.text().trim(); // Skip empty code blocks if (!code) return; // Try to determine the language const language = $el.attr("class")?.match(/language-(\w+)/)?.[1]; // Get surrounding context (e.g., nearest heading) const context = $el .closest("section") .find("h1, h2, h3, h4, h5, h6") .first() .text() .trim(); // Avoid duplicates if (!content.codeSnippets.some((snippet) => snippet.code === code)) { content.codeSnippets.push({ language, code, context: context || undefined, }); } }); return NextResponse.json({ success: true, data: content, debug: { url: response.url, snippetsFound: content.codeSnippets.length, }, }); } catch (error) { console.error("Scraping error:", error); return NextResponse.json( { error: "Failed to scrape the webpage", details: error instanceof Error ? error.message : "Unknown error", }, { status: 500 } ); } } ================================================ FILE: app/(main)/chat/[id]/layout.tsx ================================================ import React from "react"; import { createClient } from "@/utils/supabase/server"; import { redirect } from "next/navigation"; export default async function ChatLayout({ children, }: { children: React.ReactNode; }) { const supabase = await createClient(); const { data: { user }, error, } = await supabase.auth.getUser(); if (error || !user) { redirect("/login"); } return (
{children}
); } ================================================ FILE: app/(main)/chat/[id]/loading.tsx ================================================ "use client"; import React from "react"; export default function Loading() { return (
); } ================================================ FILE: app/(main)/chat/[id]/page.tsx ================================================ import MainChatPage from "@/features/chat/components/chat"; import { getChatById, getMessagesByChatId } from "@/lib/database/chat/queries"; import { notFound } from "next/navigation"; import { convertToUIMessages } from "@/features/chat/utils/general-utils"; export default async function Page(props: { params: Promise<{ id: string }> }) { const params = await props.params; const { id } = params; const chat = await getChatById(id); if (!chat) { notFound(); } const initialMessages = await getMessagesByChatId(id); return ( ); } ================================================ FILE: app/(main)/chat-history/layout.tsx ================================================ import React from "react"; import { createClient } from "@/utils/supabase/server"; import { redirect } from "next/navigation"; export default async function ChatHistoryLayout({ children, }: { children: React.ReactNode; }) { const supabase = await createClient(); const { data: { user }, error, } = await supabase.auth.getUser(); if (error || !user) { redirect("/login"); } return (
{children}
); } ================================================ FILE: app/(main)/chat-history/loading.tsx ================================================ import ChatHistoryTableSkeleton from "@/features/chat-history/components/chat-history-table-skeleton"; const loading = () => { return ; }; export default loading; ================================================ FILE: app/(main)/chat-history/page.tsx ================================================ export const dynamic = "force-dynamic"; import React from "react"; import ChatHistory from "@/features/chat-history/components/chat-history"; const ChatHistoryPage = async () => { return ; }; export default ChatHistoryPage; ================================================ FILE: app/(main)/integrations/layout.tsx ================================================ import { createClient } from "@/utils/supabase/server"; import { redirect } from "next/navigation"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const supabase = await createClient(); const { data: { user }, error, } = await supabase.auth.getUser(); if (error || !user) { redirect("/login"); } return (
{children}
); } ================================================ FILE: app/(main)/integrations/loading.tsx ================================================ "use client"; import React from "react"; export default function Loading() { return (
); } ================================================ FILE: app/(main)/integrations/page.tsx ================================================ export const dynamic = "force-dynamic"; import React from "react"; import IntegrationsPage from "@/features/integrations/components/integrations-page"; const Integrations = async () => { return ; }; export default Integrations; ================================================ FILE: app/(main)/knowledge-base/layout.tsx ================================================ import { createClient } from "@/utils/supabase/server"; import { redirect } from "next/navigation"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const supabase = await createClient(); const { data: { user }, error, } = await supabase.auth.getUser(); if (error || !user) { redirect("/login"); } return (
{children}
); } ================================================ FILE: app/(main)/knowledge-base/loading.tsx ================================================ "use client"; import React from "react"; export default function Loading() { return (
); } ================================================ FILE: app/(main)/knowledge-base/page.tsx ================================================ export const dynamic = "force-dynamic"; import KnowledgeBase from "@/features/knowledge-base/components/knolwedge-base"; import { fetchDocumentFiles } from "@/features/knowledge-base/actions/document-actions"; const KnowledgeBasePage = async () => { const documents = await fetchDocumentFiles(); return ; }; export default KnowledgeBasePage; ================================================ FILE: app/(main)/layout.tsx ================================================ import localFont from "next/font/local"; import "./styles.css"; import "maplibre-gl/dist/maplibre-gl.css"; import "@blocknote/mantine/style.css"; import { createClient } from "@/utils/supabase/server"; import { redirect } from "next/navigation"; import { Analytics } from "@vercel/analytics/next"; import { getUserProfile } from "./actions/get-user-profile"; import ClientWrapper from "@/components/client-wrapper"; import { TooltipProvider } from "@/components/ui/tooltip"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", variable: "--font-geist-sans", weight: "100 900", }); const geistMono = localFont({ src: "./fonts/GeistMonoVF.woff", variable: "--font-geist-mono", weight: "100 900", }); // Define metadata for the root layout export const metadata = { title: "Chat2Geo", description: "AI-powered geospatial analyticse", }; export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { const supabase = await createClient(); const { data: authResults, error } = await supabase.auth.getUser(); if (error || !authResults?.user) { redirect("/login"); } const userProfile = await getUserProfile(); return ( {children} ); } ================================================ FILE: app/(main)/loading.tsx ================================================ "use client"; import React from "react"; export default function Loading() { return (
); } ================================================ FILE: app/(main)/page.tsx ================================================ export const dynamic = "force-dynamic"; import MainChatPage from "@/features/chat/components/chat"; import "@blocknote/mantine/style.css"; import { generateUUID } from "@/features/chat/utils/general-utils"; export default async function Home() { const chatId = generateUUID(); return (
); } ================================================ FILE: app/(main)/styles.css ================================================ /* ------------------------------------------- Tailwind Base Imports ------------------------------------------- */ @tailwind base; @tailwind components; @tailwind utilities; /* ------------------------------------------- Theme Variables & Base Styles ------------------------------------------- */ @layer base { :root { /* Light Theme Variables */ --background: 0 0% 100%; --foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 47.4% 11.2%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --card: 0 0% 100%; --card-foreground: 222.2 47.4% 11.2%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --primary-blue: 220 90% 56%; --primary-blue-foreground: 210 40% 98%; --primary-green: 145 63% 42%; --primary-green-foreground: 210 40% 98%; --secondary: 220 13% 92%; --secondary-foreground: 220 10% 40%; --accent: 220 15% 85%; --accent-foreground: 220 20% 12%; --destructive: 0 100% 50%; --destructive-foreground: 210 40% 98%; --warning: 45 100% 50%; --warning-foreground: 45 100% 15%; --info: 200 98% 48%; --info-foreground: 210 40% 98%; --ring: 215 20.2% 65.1%; --radius: 0.5rem; --shimmer-highlight: rgba(255, 255, 255, 0.6); } .dark { /* Dark Theme Variables */ /* Base */ --background: 240 2% 12%; /* #1f1f21 */ --foreground: 0 0% 95%; /* Very light neutral */ /* Cards/Popovers */ --card: 220 3% 10%; /* #18191a */ --card-foreground: 0 0% 95%; --popover: 220 3% 10%; --popover-foreground: 0 0% 95%; /* Muted */ --muted: 240 2% 16%; --muted-foreground: 0 0% 60%; /* Interactive elements */ --accent: 240 2% 18%; --accent-foreground: 0 0% 98%; /* Borders & Rings */ --border: 240 2% 20%; --input: 240 2% 20%; --ring: 240 2% 20%; /* Primary */ --primary: 0 0% 98%; --primary-foreground: 240 2% 12%; /* Secondary */ --secondary: 240 2% 22%; --secondary-foreground: 0 0% 98%; /* Semantic Colors */ --primary-blue: 220 90% 56%; --primary-blue-foreground: 210 40% 98%; --primary-green: 145 63% 42%; --primary-green-foreground: 210 40% 98%; --destructive: 0 50% 35%; --destructive-foreground: 0 0% 98%; --warning: 45 100% 50%; --warning-foreground: 45 100% 15%; --info: 200 98% 48%; --info-foreground: 210 40% 98%; --shimmer-highlight: rgba(255, 255, 255, 0.1); } /* Universal defaults */ * { @apply border-border; } body { @apply font-sans antialiased bg-background text-foreground; } } /* ------------------------------------------- Keyframes & Animation Classes ------------------------------------------- */ @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: 0% 0; } } .animate-shimmer { display: inline-block; color: transparent; background: linear-gradient( 90deg, #1e90ff, #34d399, #a3e635, #ffd700, #ff8c00, #ff69b4, #ff007f, #7928ca, #1e90ff ); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; animation: shimmer 3s linear infinite; } @keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .fade-in { animation: fadeIn 0.6s ease-in-out; } /* ------------------------------------------- MapLibre GL Styles ------------------------------------------- */ /* Remove default focus ring for MapLibre canvas */ .maplibregl-canvas:focus { outline: none !important; box-shadow: none !important; } /* Attribution text */ .maplibregl-ctrl-attrib-inner { font-size: 12px !important; white-space: nowrap; padding: 2px 2px; } /* Popup content for light theme (default) */ .maplibregl-popup-content { background-color: hsl(var(--background)); color: hsl(var(--foreground)); border-radius: var(--radius); padding: 8px 10px; box-shadow: 0 2px 6px hsl(var(--foreground) / 0.2); } .maplibregl-popup-tip { border-top-color: hsl(var(--background)) !important; } /* Popup content for dark theme */ :is([data-theme="dark"], .dark) .maplibregl-popup-content { background-color: hsl(var(--background)); color: hsl(var(--foreground)); } :is([data-theme="dark"], .dark) .maplibregl-popup-tip { border-top-color: hsl(var(--background)) !important; } /* Scale control (light) */ .maplibregl-ctrl.maplibregl-ctrl-scale { background-color: hsl(var(--background) / 0.75); border-color: hsl(var(--muted-foreground)); color: hsl(var(--foreground)); } /* Base for maplibre control buttons */ .maplibregl-ctrl button { background-color: hsl(var(--background)); } /* Control group styling (light) */ .maplibregl-ctrl-group { background: hsl(var(--background)); display: inline-flex !important; flex-direction: row !important; } /* Reset button styles, remove default borders */ .maplibregl-ctrl-group > button { border: none !important; border-radius: 0 !important; } /* Add divider between adjacent buttons */ .maplibregl-ctrl-group > button + button { border-left: 2px solid hsl(var(--border)) !important; } /* Round left/right edges of group */ .maplibregl-ctrl-group > button:first-of-type { border-top-left-radius: var(--radius) !important; border-bottom-left-radius: var(--radius) !important; } .maplibregl-ctrl-group > button:last-of-type { border-top-right-radius: var(--radius) !important; border-bottom-right-radius: var(--radius) !important; } /* Add subtle box-shadow for group */ .maplibregl-ctrl-group:not(:empty) { box-shadow: 0 0 0 2px hsl(var(--border) / 0.1); } /* Dark theme overrides for scale control & buttons */ :is([data-theme="dark"], .dark) .maplibregl-ctrl.maplibregl-ctrl-scale { background-color: hsl(var(--background) / 0.75); border-color: hsl(var(--muted-foreground)); color: hsl(var(--foreground)); } :is([data-theme="dark"], .dark) .maplibregl-ctrl button { background-color: hsl(var(--background)); } :is([data-theme="dark"], .dark) .maplibregl-ctrl-group { background: hsl(var(--background)); } :is([data-theme="dark"], .dark) .maplibregl-ctrl-group:not(:empty) { box-shadow: 0 0 0 2px hsl(var(--border) / 0.1); } :is([data-theme="dark"], .dark) .maplibregl-ctrl button .maplibregl-ctrl-icon { filter: invert(1) brightness(100); } :is([data-theme="dark"], .dark) .maplibregl-ctrl button:hover { background-color: hsl(var(--muted)); } /* Attribution control (light) */ .maplibregl-ctrl.maplibregl-ctrl-attrib { background-color: hsl(var(--background) / 0.6); border-radius: var(--radius); } .maplibregl-ctrl-attrib-inner { background-color: transparent; color: hsl(var(--foreground)); border-radius: var(--radius); } /* Attribution control (dark) */ :is([data-theme="dark"], .dark) .maplibregl-ctrl.maplibregl-ctrl-attrib { background-color: hsl(var(--background) / 0.6); color: hsl(var(--foreground)); border-radius: var(--radius); } :is([data-theme="dark"], .dark) .maplibregl-ctrl-attrib-inner { background-color: transparent; color: hsl(var(--foreground)); border-radius: var(--radius); } /* ------------------------------------------- BN Container Overrides ------------------------------------------- */ .bn-container[data-color-scheme="light"] { --bn-colors-editor-background: hsl(var(--background)) !important; } .bn-container[data-color-scheme="dark"] { --bn-colors-editor-background: hsl(var(--background)) !important; } /* ------------------------------------------- Misc. Utilities ------------------------------------------- */ .custom-scrollbar { scrollbar-width: thin; /* Firefox */ } /* Loading overlay */ .page-loading-overlay { @apply fixed inset-0 z-[9999] flex items-center justify-center bg-transparent; } /* Loading spinner */ .page-loading-spinner { @apply inline-block h-10 w-10 animate-spin rounded-full border-4 border-blue-400 border-t-transparent; } ================================================ FILE: app/actions/rag-actions.ts ================================================ "use server"; import { createClient } from "@/utils/supabase/server"; import { WebPDFLoader } from "@langchain/community/document_loaders/web/pdf"; import { SupabaseVectorStore } from "@langchain/community/vectorstores/supabase"; import { generateEmbeddings } from "@/features/knowledge-base/lib/generate-embeddings"; import { generateChunks } from "@/features/knowledge-base/lib/generate-embeddings"; import { cleanString } from "@/utils/general/general-utils"; export async function saveRagDocument( file: any, numberOfPages: number, folderId: string | null ) { const supabase = await createClient(); const { data: authResult, error: userError } = await supabase.auth.getUser(); if (userError || !authResult?.user) { throw new Error("Unauthenticated!"); } const bucketName = "documents_bucket"; const filePath = `${authResult.user.id}/${file.name}`; const { error: uploadError } = await supabase.storage .from(bucketName) .upload(filePath, file); if (uploadError) throw new Error(`Failed to upload file: ${uploadError.message}`); const { data: signedUrlData, error: signedUrlError } = await supabase.storage .from(bucketName) .createSignedUrl(filePath, 60 * 60 * 1); // Signed URL valid for 1 hour if (signedUrlError) throw new Error(`Failed to create signed URL: ${signedUrlError.message}`); const fileSignedURL = signedUrlData?.signedUrl; if (!fileSignedURL) { throw new Error("Failed to retrieve the file's signed URL."); } const { data: fileData, error: fileError } = await supabase .from("document_files") .insert({ name: file.name, owner: authResult.user.id, number_of_pages: numberOfPages, file_path: fileSignedURL, folder_id: folderId ?? null, }) .select() .single(); if (fileError) throw fileError; const loader = new WebPDFLoader(file); const output = await loader.load(); const docs = output.map((d) => ({ ...d, metadata: { ...d.metadata, fileName: file.name, fileId: fileData.id, ownerId: authResult.user.id, }, })); const splittedDocs = await generateChunks.splitDocuments(docs); const contents = splittedDocs.map((doc) => doc.pageContent); const embeddings = await generateEmbeddings.embedDocuments(contents); const sanitizedEmbeddingsData = splittedDocs.map((doc, index) => ({ content: cleanString(doc.pageContent), metadata: doc.metadata, embedding: embeddings[index], file_id: fileData.id, })); const { error: embeddingsError } = await supabase .from("embeddings") .insert(sanitizedEmbeddingsData); if (embeddingsError) { console.error("Insert Error Details:", embeddingsError); throw new Error(`Failed to insert embeddings: ${embeddingsError.message}`); } return fileData; } export async function answerQuery(query: string) { const supabase = await createClient(); const { data: authResult, error: userError } = await supabase.auth.getUser(); if (userError || !authResult?.user) { throw new Error("Unauthenticated!"); } const userId = authResult.user.id; const vectorStore = await SupabaseVectorStore.fromExistingIndex( generateEmbeddings, { client: supabase, tableName: "embeddings", queryName: "search_documents_by_similarity", } ); const retriever = vectorStore.asRetriever({ k: 5, filter: { owner: userId }, }); let topMatches = await retriever._getRelevantDocuments(query); topMatches = topMatches.filter((match) => match.metadata.similarity > 0.4); const matchesByPage = topMatches.reduce((acc, doc) => { const pageNumber = doc.metadata.loc.pageNumber; const currentBest = acc[pageNumber]; if ( !currentBest || doc.metadata.similarity > currentBest.metadata.similarity ) { acc[pageNumber] = doc; } return acc; }, {} as Record); return Object.values(matchesByPage); } ================================================ FILE: components/changelog-modal.tsx ================================================ "use client"; import React, { useState, useEffect } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import ReactMarkdown from "react-markdown"; import { changelog } from "@/lib/changelog"; export default function ChangelogModal() { const [hasMounted, setHasMounted] = useState(false); const [open, setOpen] = useState(false); useEffect(() => { setHasMounted(true); if (changelog.length > 0) { const newest = changelog[0]; const storedVersion = localStorage.getItem("changelog_last_seen_version"); if (storedVersion !== newest.version) { setOpen(true); } } }, []); if (!hasMounted) return null; const latestEntry = changelog[0]; if (!latestEntry) return null; return ( { setOpen(value); if (!value) { localStorage.setItem( "changelog_last_seen_version", latestEntry.version ); } }} > Chat2Geo Updated (Version {latestEntry.version}) -{" "} {latestEntry.date} {latestEntry.content} ); } ================================================ FILE: components/client-hydrator.tsx ================================================ "use client"; import { useEffect } from "react"; import { useUserStore } from "@/stores/use-user-profile-store"; interface ClientHydratorProps { userProfile: { email: string; name: string; role: string; organization: string; licenseStart: string; licenseEnd: string; } | null; } export default function ClientHydrator({ userProfile }: ClientHydratorProps) { const { setUserData } = useUserStore(); useEffect(() => { if (userProfile) { setUserData( userProfile.name, userProfile.email, userProfile.role, userProfile.organization, userProfile.licenseStart, userProfile.licenseEnd ); } }, [userProfile, setUserData]); return null; } ================================================ FILE: components/client-wrapper.tsx ================================================ // components/client-wrapper.tsx "use client"; import React from "react"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "react-hot-toast"; import ToastMessage from "@/features/ui/toast-message"; import MainSidebar from "@/components/main-sidebar/main-sidebar"; import ClientHydrator from "@/components/client-hydrator"; import ChangelogModal from "@/components/changelog-modal"; export default function ClientWrapper({ userProfile, children, }: { userProfile: any; children: React.ReactNode; }) { return ( {children} ); } ================================================ FILE: components/document-viewer.tsx ================================================ "use client"; import React from "react"; import { useDocumentViewer } from "@/hooks/docs-hooks/use-document-viewer"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; interface DocumentViewerProps { documentName: string; pageNumber: number; onClose: () => void; } export function DocumentViewer({ documentName, pageNumber, onClose, }: DocumentViewerProps) { const { pdfUrl, error, isLoading } = useDocumentViewer(documentName); // Render any error in a Dialog if (error) { return ( onClose()}> Error {error} ); } // Otherwise, show the PDF in a Dialog return ( onClose()}> {documentName} (Page {pageNumber})
{/* If pdfUrl is present, show an iframe with #page=pageNumber */} {pdfUrl && (