Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
64597e8
resolved illegal threading issue; close #32
iamtekson Jan 5, 2026
1d7457b
v0.2.0
iamtekson Jan 5, 2026
451338a
Merge branch 'main' of github.com:iamtekson/GeoAgent into dev
iamtekson Jan 5, 2026
b1650cc
logger in filter functions
iamtekson Jan 5, 2026
94f8641
Initial plan
Copilot Jan 5, 2026
4034bc7
Initial plan
Copilot Jan 5, 2026
8ea1269
Add error handling for QMetaObject.invokeMethod return value
Copilot Jan 5, 2026
f0dd0ef
remove unnecessary imports and try/except block
iamtekson Jan 5, 2026
085cca3
Fix race condition in MainThreadRunner using thread-safe storage
Copilot Jan 5, 2026
5d37da7
Add explicit check for thread result existence
Copilot Jan 5, 2026
395a329
Merge pull request #39 from iamtekson/copilot/sub-pr-38
iamtekson Jan 5, 2026
f1c71c2
Merge branch 'dev' into copilot/sub-pr-38-again
iamtekson Jan 5, 2026
760a63b
Merge pull request #40 from iamtekson/copilot/sub-pr-38-again
iamtekson Jan 5, 2026
5785214
resolve multi-threading issue
iamtekson Jan 5, 2026
76f60ae
Initial plan
Copilot Jan 5, 2026
ba7e60d
Initial plan
Copilot Jan 5, 2026
2774e53
Update utils/canvas_refresh.py
iamtekson Jan 5, 2026
5cb9511
Update tools/io.py
iamtekson Jan 5, 2026
1ac7468
Move MainThreadRunner instantiation to initGui() method
Copilot Jan 5, 2026
7ca9199
Add defensive check for MainThreadRunner in run() method
Copilot Jan 5, 2026
7f32ff3
Fix memory leak in MainThreadRunner by adding try-finally cleanup
Copilot Jan 5, 2026
bcb107b
Address code review feedback: update comments and add warning
Copilot Jan 5, 2026
c712ff1
Improve comments and use single quotes for consistency
Copilot Jan 5, 2026
1dbe844
Refactor cleanup to use single pop in finally block
Copilot Jan 5, 2026
8cca0eb
Improve documentation for MainThreadRunner setup
Copilot Jan 5, 2026
8c6657d
Merge branch 'dev' into copilot/sub-pr-38
iamtekson Jan 5, 2026
ec61662
Merge pull request #41 from iamtekson/copilot/sub-pr-38
iamtekson Jan 5, 2026
d16b385
Merge branch 'dev' into copilot/sub-pr-38-again
iamtekson Jan 5, 2026
6546d00
Merge pull request #42 from iamtekson/copilot/sub-pr-38-again
iamtekson Jan 5, 2026
626234e
modified UI main page
iamtekson Jan 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 2 additions & 19 deletions dialogs/geo_agent_dialog_base.ui
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<item row="0" column="0">
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>1</number>
<number>0</number>
</property>
<widget class="QWidget" name="tab">
<attribute name="title">
Expand All @@ -29,23 +29,6 @@
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="layout_custom">
<!-- <item>
<widget class="QTextEdit" name="llm_response_edit">
<property name="sizePolicy">
<sizepolicy hsizetype="Ignored" vsizetype="Ignored">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="html">
<string>&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
&lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
p, li { white-space: pre-wrap; }
&lt;/style&gt;&lt;/head&gt;&lt;body style=&quot; font-family:'MS Shell Dlg 2'; font-size:7.875pt; font-weight:400; font-style:normal;&quot;&gt;
&lt;p style=&quot;-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:7.8pt;&quot;&gt;&lt;br /&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
</widget>
</item> -->
<item>
<widget class="QTextBrowser" name="llm_response">
<property name="sizePolicy">
Expand Down Expand Up @@ -290,7 +273,7 @@ p, li { white-space: pre-wrap; }
<item>
<widget class="QLabel" name="label_5">
<property name="text">
<string>Temperature:</string>
<string>Temperature:</string>
</property>
</widget>
</item>
Expand Down
155 changes: 27 additions & 128 deletions geo_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@
QCoreApplication,
Qt,
QThread,
QMetaObject,
QTimer,
Q_ARG,
QEventLoop,
)
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction, QMessageBox, QSizePolicy, QProgressDialog
Expand All @@ -53,17 +49,9 @@
from .llm.worker import LLMWorker
from .prompts.system import GENERAL_SYSTEM_PROMPT
from .utils.canvas_refresh import (
RefreshDispatcher,
set_main_runner,
set_qgis_interface,
set_refresh_callback,
)
from .utils.layer_operations import (
LayerRemovalDispatcher,
set_layer_removal_callback,
)
from .utils.project_loader import (
ProjectLoadDispatcher,
set_project_load_callback,
MainThreadRunner
)
from .utils.markdown_converter import markdown_to_html
from typing import Optional
Expand Down Expand Up @@ -91,15 +79,9 @@ def __init__(self, iface):
"""
# Save reference to the QGIS interface
self.iface = iface

# Dispatcher for main-thread canvas refresh
self._refresh_dispatcher = RefreshDispatcher(self.iface)

# Dispatcher for main-thread project loading
self._project_load_dispatcher = ProjectLoadDispatcher(self.iface)

# Dispatcher for main-thread layer removal
self._layer_removal_dispatcher = LayerRemovalDispatcher(self.iface)

# main runner - will be initialized in initGui() on the main thread
self.main_runner = None

# initialize plugin directory
self.plugin_dir = os.path.dirname(__file__)
Expand Down Expand Up @@ -259,94 +241,6 @@ def _on_qgis_message(self, message, tag, level):
except Exception:
pass

def _refresh_callback(self):
"""Thread-safe refresh callback invoked by tools. Queues a dispatcher slot on the main thread."""
try:
ok = QMetaObject.invokeMethod(
self._refresh_dispatcher,
"doRefresh",
Qt.QueuedConnection,
)
if not ok:
QgsMessageLog.logMessage(
"QMetaObject.invokeMethod('doRefresh') returned False; "
"falling back to QTimer.singleShot.",
"GeoAgent",
level=Qgis.Warning,
)
QTimer.singleShot(0, self._refresh_dispatcher.doRefresh)
except Exception as e:
self._log_error("refresh_callback", e)

def _project_load_callback(self, path):
"""Thread-safe project load callback using signals."""
try:

def on_result_ready():
loop.quit()

# connect signal to quit when result is ready
self._project_load_dispatcher.result_ready.connect(on_result_ready)

# queue the load operation on the main thread
QMetaObject.invokeMethod(
self._project_load_dispatcher,
"doLoadProject",
Qt.QueuedConnection,
Q_ARG(str, path),
)
# wait for the dispatcher to signal completion
loop = QEventLoop()

# max wait 30 seconds timeout
QTimer.singleShot(30000, loop.quit)
loop.exec_()

# disconnect signal to avoid memory leaks
self._project_load_dispatcher.result_ready.disconnect(on_result_ready)

# return the result that was set by the dispatcher
return self._project_load_dispatcher.result
except Exception as e:
error_msg = f"_project_load_callback error: {e}"
QgsMessageLog.logMessage(error_msg, "GeoAgent", level=Qgis.Warning)
return {"success": False, "error": error_msg}

def _layer_removal_callback(self, layer_id, layer_name):
"""Thread-safe layer removal callback using signals."""
try:

def on_result_ready():
loop.quit()

# connect signal to quit when result is ready
self._layer_removal_dispatcher.result_ready.connect(on_result_ready)

# queue the removal operation on the main thread
QMetaObject.invokeMethod(
self._layer_removal_dispatcher,
"doRemoveLayer",
Qt.QueuedConnection,
Q_ARG(str, layer_id),
Q_ARG(str, layer_name),
)
# wait for the dispatcher to signal completion
loop = QEventLoop()

# max wait 10 seconds timeout
QTimer.singleShot(10000, loop.quit)
loop.exec_()

# disconnect signal to avoid memory leaks
self._layer_removal_dispatcher.result_ready.disconnect(on_result_ready)

# return the result that was set by the dispatcher
return self._layer_removal_dispatcher.result
except Exception as e:
error_msg = f"_layer_removal_callback error: {e}"
QgsMessageLog.logMessage(error_msg, "GeoAgent", level=Qgis.Warning)
return {"success": False, "error": error_msg}

def _ensure_dependencies_installed(self):
"""Ensure required Python packages are installed via pyproject.toml."""
pkg_to_import = {
Expand Down Expand Up @@ -589,6 +483,10 @@ def add_action(
def initGui(self):
"""Create the menu entries and toolbar icons inside the QGIS GUI."""

# Initialize MainThreadRunner on the main Qt thread
# Qt objects should be created on the thread where they will live
self.main_runner = MainThreadRunner()

icon_path = os.path.join(self.plugin_dir, "icons", "icon.png")
self.add_action(
icon_path,
Expand Down Expand Up @@ -622,21 +520,24 @@ def run(self):
# Ensure dependencies before loading graph or message classes
self._ensure_dependencies_installed()

# Initialize QGIS interface for tools
try:
# Register QGIS interface for tools module
set_qgis_interface(self.iface)

# Register thread-safe refresh callback method
set_refresh_callback(self._refresh_callback)
# Ensure MainThreadRunner is initialized (should be done in initGui())
# MainThreadRunner is a QObject that must be created on the main Qt thread
# If it's None here, initGui() was not called properly by QGIS
if self.main_runner is None:
# This should not happen in normal QGIS plugin lifecycle
# Log a warning as this indicates a problem with plugin initialization
QgsMessageLog.logMessage(
'MainThreadRunner was not initialized in initGui(). '
'This may indicate a problem with plugin initialization.',
'GeoAgent',
level=Qgis.Warning
)
# Create it here as a fallback, though this is not ideal for threading
self.main_runner = MainThreadRunner()

# Register thread-safe project load callback method
set_project_load_callback(self._project_load_callback)

# Register thread-safe layer removal callback method
set_layer_removal_callback(self._layer_removal_callback)
except Exception as e:
self._log_error("initialize_tools_interface", e)
# Set global main runner for canvas refresh utility
set_main_runner(self.main_runner)
set_qgis_interface(self.iface)

# Create the dialog with elements (after translation) and keep reference
# Only create GUI ONCE in callback, so that it will only load when the plugin is started
Expand Down Expand Up @@ -824,9 +725,7 @@ def send_message(self):
# self.dlg.send_chat.setText("Processing...")

# Create and start worker thread for non-blocking inference
self._worker_thread = LLMWorker(
self.app, self.thread_id, msgs, invoke_app_async
)
self._worker_thread = LLMWorker(self.app, self.thread_id, msgs, invoke_app_async)
self._worker_thread.result_ready.connect(self._on_invoke_result)
self._worker_thread.error.connect(self._on_invoke_error)
self._worker_thread.finished.connect(self._on_invoke_finished)
Expand Down
6 changes: 5 additions & 1 deletion metadata.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
name=GeoAgent
qgisMinimumVersion=3.2
description=Plugin for QGIS interaction using LLM. Enables geospatial analysis and data processing through natural language commands.
version=0.1.1
version=0.2.0
author=Tek Kshetri, Rabin Ojha
email=iamtekson@gmail.com, rabenojha@gmail.com

Expand All @@ -16,6 +16,10 @@ license=MIT
tracker=https://github.com/iamtekson/GeoAgent/issues
repository=https://github.com/iamtekson/GeoAgent
changelog=
v0.2.0
- Improved UI help messages and user notifications.
- Enhanced error handling for project operations.
- Resolved issue with illegal threading (QGIS crash) during project load/save/create/delete operations.
v0.1.1
- Fixed dependency installation issues on some systems.
v0.1
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "geo-agent"
version = "0.1.1"
version = "0.2.0"
description = "Plugin for QGIS interaction using LLM. Enables geospatial analysis and data processing through natural language commands."
readme = "README.md"
requires-python = ">=3.13"
Expand Down
3 changes: 2 additions & 1 deletion tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@
select_by_geometry,
)

# for easy importing geoprocessing tools later
from .geoprocessing import (
execute_processing,
list_processing_algorithms,
get_algorithm_parameters,
find_processing_algorithm,
)
)

# Aggregate all tools for easy import
TOOLS = {
Expand Down
22 changes: 17 additions & 5 deletions tools/filters.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from ..utils.canvas_refresh import refresh_map_canvas
from ..utils.canvas_refresh import get_qgis_interface, qgis_main_thread
from typing import Optional
from qgis.core import (
QgsProject,
Expand All @@ -8,9 +8,12 @@
)

from langchain_core.tools import tool
from ..logger.processing_logger import get_processing_logger

_logger = get_processing_logger()

@tool
@qgis_main_thread
def select_by_attribute(
layer_name: str,
field_name: str,
Expand All @@ -34,6 +37,7 @@ def select_by_attribute(
- select_by_attribute('roads', 'type', '=', 'highway')
- select_by_attribute('names', 'name', 'starts_with', 'New')
"""
_logger.info(f"Selecting by attribute on layer '{layer_name}', field '{field_name}', operator '{operator}', value '{value}'")
try:
project = QgsProject.instance()

Expand All @@ -54,6 +58,7 @@ def select_by_attribute(
field_index = layer.fields().indexFromName(field_name)
if field_index == -1:
available_fields = [f.name() for f in layer.fields()]
_logger.error(f"Field '{field_name}' not found in layer '{layer_name}'. Available fields: {', '.join(available_fields)}")
return f"**Error:** Field **{field_name}** not found. Available fields: {', '.join(available_fields)}"

# Build expression based on operator
Expand Down Expand Up @@ -98,16 +103,19 @@ def select_by_attribute(

layer.selectByIds(selected_ids)

# Highlight on map
refresh_map_canvas()
iface = get_qgis_interface()
iface.mapCanvas().refresh()

_logger.info(f"Selected {len(selected_ids)} features in layer '{layer_name}' using attribute filter.")
return f"**Success:** Selected {len(selected_ids)} features in **{layer_name}** where **{field_name}** {operator} '{value}'."

except Exception as e:
_logger.error(f"Error selecting by attribute: {str(e)}", exc_info=True)
return f"**Error:** selecting by attribute: {str(e)}"


@tool
@qgis_main_thread
def select_by_geometry(
layer_name: str,
geometry_filter: str,
Expand All @@ -129,6 +137,7 @@ def select_by_geometry(
- select_by_geometry('roads', 'intersecting', 'study_area')
- select_by_geometry('points', 'inside', 'boundary')
"""
_logger.info(f"Selecting by geometry on layer '{layer_name}' using filter '{geometry_filter}' with reference layer '{reference_layer_name}'")
try:
project = QgsProject.instance()

Expand Down Expand Up @@ -223,17 +232,20 @@ def select_by_geometry(
selected_ids.append(feature.id())

else:
_logger.error(f"Unknown geometry filter '{geometry_filter}'")
return f"**Error:** Unknown geometry filter **{geometry_filter}**. Use: 'largest', 'smallest', 'intersecting', 'inside', 'touching'."

# Apply selection
layer.selectByIds(selected_ids)

# Highlight on map
refresh_map_canvas()
iface = get_qgis_interface()
iface.mapCanvas().refresh()

_logger.info(f"Selected {len(selected_ids)} features in layer '{layer_name}' using geometry filter.")
return f"**Success:** Selected {len(selected_ids)} features in **{layer_name}** using **{geometry_filter}** filter."

except Exception as e:
_logger.error(f"Error selecting by geometry: {str(e)}", exc_info=True)
return f"**Error:** selecting by geometry: {str(e)}"


Expand Down
2 changes: 1 addition & 1 deletion tools/geoprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from ..config.constants import RASTER_EXTENSIONS


# TODO: test this function for both raster and vector outputs

@tool
def execute_processing(algorithm: str, parameters: dict, **kwargs) -> dict:
"""
Expand Down
Loading