Addresses multiple issues related to GTK4 Layer Shell initialization and Ollama integration. - Reorders initialization to ensure the layer shell is set up before window properties. - Adds error detection for layer shell initialization failures. - Implements a focus event handler to prevent focus-out warnings. - Introduces a launcher script to activate the virtual environment, force native Wayland, and preload the GTK4 Layer Shell library. - Warns users of incorrect GDK_BACKEND settings. - Updates the Ollama client to handle responses from both older and newer versions of the Ollama SDK. These changes improve the application's stability, compatibility, and functionality on Wayland systems.
224 lines
8.3 KiB
Python
224 lines
8.3 KiB
Python
"""GTK sidebar window definitions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from typing import Iterable
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gtk", "4.0")
|
|
from gi.repository import GLib, Gtk # noqa: E402
|
|
|
|
try: # pragma: no cover - optional dependency may not be available in CI
|
|
gi.require_version("Gtk4LayerShell", "1.0")
|
|
from gi.repository import Gtk4LayerShell # type: ignore[attr-defined]
|
|
except (ImportError, ValueError): # pragma: no cover - fallback path
|
|
Gtk4LayerShell = None # type: ignore[misc]
|
|
|
|
from conversation_manager import ConversationManager
|
|
from ollama_client import OllamaClient
|
|
|
|
|
|
class SidebarWindow(Gtk.ApplicationWindow):
|
|
"""Layer-shell anchored window hosting the chat interface."""
|
|
|
|
def __init__(self, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
|
|
# CRITICAL: Layer shell must be initialized BEFORE any window properties
|
|
self._setup_layer_shell()
|
|
|
|
self.set_default_size(360, 720)
|
|
self.set_title("Niri AI Sidebar")
|
|
self.set_hide_on_close(False)
|
|
|
|
self._conversation_manager = ConversationManager()
|
|
self._ollama_client = OllamaClient()
|
|
self._current_model = self._ollama_client.default_model
|
|
|
|
self._build_ui()
|
|
self._populate_initial_messages()
|
|
|
|
# ------------------------------------------------------------------ UI setup
|
|
def _setup_layer_shell(self) -> None:
|
|
"""Attach the window to the left edge via gtk4-layer-shell when available."""
|
|
if Gtk4LayerShell is None:
|
|
return
|
|
|
|
Gtk4LayerShell.init_for_window(self)
|
|
|
|
# Verify initialization succeeded before configuring layer shell properties
|
|
if not Gtk4LayerShell.is_layer_window(self):
|
|
return
|
|
|
|
Gtk4LayerShell.set_namespace(self, "niri-ai-sidebar")
|
|
Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP)
|
|
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
|
|
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
|
|
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
|
|
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, False)
|
|
Gtk4LayerShell.set_margin(self, Gtk4LayerShell.Edge.LEFT, 0)
|
|
Gtk4LayerShell.set_keyboard_mode(
|
|
self, Gtk4LayerShell.KeyboardMode.ON_DEMAND
|
|
)
|
|
Gtk4LayerShell.set_exclusive_zone(self, -1)
|
|
|
|
def _build_ui(self) -> None:
|
|
"""Create the core layout: message history and input entry."""
|
|
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
|
main_box.set_margin_top(16)
|
|
main_box.set_margin_bottom(16)
|
|
main_box.set_margin_start(16)
|
|
main_box.set_margin_end(16)
|
|
main_box.set_hexpand(True)
|
|
main_box.set_vexpand(True)
|
|
|
|
header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
|
header_title = Gtk.Label(label="Niri AI Sidebar")
|
|
header_title.set_halign(Gtk.Align.START)
|
|
header_title.get_style_context().add_class("title-2")
|
|
|
|
model_name = self._current_model or "No local model detected"
|
|
self._model_label = Gtk.Label(label=f"Model: {model_name}")
|
|
self._model_label.set_halign(Gtk.Align.START)
|
|
self._model_label.get_style_context().add_class("dim-label")
|
|
|
|
header_box.append(header_title)
|
|
header_box.append(self._model_label)
|
|
|
|
self._message_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
self._message_list.set_hexpand(True)
|
|
self._message_list.set_vexpand(True)
|
|
self._message_list.set_valign(Gtk.Align.START)
|
|
|
|
scroller = Gtk.ScrolledWindow()
|
|
scroller.set_hexpand(True)
|
|
scroller.set_vexpand(True)
|
|
scroller.set_child(self._message_list)
|
|
scroller.set_min_content_height(300)
|
|
self._scroller = scroller
|
|
|
|
input_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
input_box.set_hexpand(True)
|
|
|
|
self._entry = Gtk.Entry()
|
|
self._entry.set_hexpand(True)
|
|
self._entry.set_placeholder_text("Ask a question…")
|
|
self._entry.connect("activate", self._on_submit)
|
|
|
|
# Add focus event controller to properly handle focus-out events
|
|
# The handler must return False to propagate the event to GTK's default handler
|
|
focus_controller = Gtk.EventControllerFocus()
|
|
focus_controller.connect("leave", lambda c: False)
|
|
self._entry.add_controller(focus_controller)
|
|
|
|
self._send_button = Gtk.Button(label="Send")
|
|
self._send_button.connect("clicked", self._on_submit)
|
|
|
|
input_box.append(self._entry)
|
|
input_box.append(self._send_button)
|
|
|
|
main_box.append(header_box)
|
|
main_box.append(scroller)
|
|
main_box.append(input_box)
|
|
|
|
self.set_child(main_box)
|
|
|
|
def _populate_initial_messages(self) -> None:
|
|
"""Render conversation history stored on disk."""
|
|
for message in self._conversation_manager.messages:
|
|
self._append_message(message["role"], message["content"], persist=False)
|
|
|
|
if not self._conversation_manager.messages:
|
|
self._append_message(
|
|
"assistant",
|
|
"Welcome! Ask a question to start a conversation.",
|
|
persist=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------ helpers
|
|
def _append_message(
|
|
self, role: str, content: str, *, persist: bool = True
|
|
) -> None:
|
|
"""Add a message bubble to the history and optionally persist it."""
|
|
label_prefix = "You" if role == "user" else "Assistant"
|
|
label = Gtk.Label(label=f"{label_prefix}: {content}")
|
|
label.set_halign(Gtk.Align.START)
|
|
label.set_xalign(0.0)
|
|
label.set_wrap(True)
|
|
label.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
|
label.set_justify(Gtk.Justification.LEFT)
|
|
|
|
self._message_list.append(label)
|
|
self._scroll_to_bottom()
|
|
|
|
if persist:
|
|
self._conversation_manager.append_message(role, content)
|
|
|
|
def _scroll_to_bottom(self) -> None:
|
|
"""Ensure the most recent message is visible."""
|
|
def _scroll() -> bool:
|
|
adjustment = self._scroller.get_vadjustment()
|
|
if adjustment is not None:
|
|
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size())
|
|
return False
|
|
|
|
GLib.idle_add(_scroll)
|
|
|
|
def _set_input_enabled(self, enabled: bool) -> None:
|
|
self._entry.set_sensitive(enabled)
|
|
self._send_button.set_sensitive(enabled)
|
|
|
|
# ------------------------------------------------------------------ callbacks
|
|
def _on_submit(self, _widget: Gtk.Widget) -> None:
|
|
"""Handle send button clicks or entry activation."""
|
|
text = self._entry.get_text().strip()
|
|
if not text:
|
|
return
|
|
|
|
self._entry.set_text("")
|
|
self._append_message("user", text, persist=True)
|
|
self._request_response()
|
|
|
|
def _request_response(self) -> None:
|
|
"""Trigger a synchronous Ollama chat call on a worker thread."""
|
|
model = self._current_model or self._ollama_client.default_model
|
|
if not model:
|
|
self._append_message(
|
|
"assistant",
|
|
"No Ollama models are available. Install a model to continue.",
|
|
persist=True,
|
|
)
|
|
return
|
|
|
|
history = self._conversation_manager.chat_messages
|
|
self._set_input_enabled(False)
|
|
|
|
def _worker(messages: Iterable[dict[str, str]]) -> None:
|
|
response = self._ollama_client.chat(model=model, messages=list(messages))
|
|
GLib.idle_add(self._handle_response, response, priority=GLib.PRIORITY_DEFAULT)
|
|
|
|
thread = threading.Thread(target=_worker, args=(history,), daemon=True)
|
|
thread.start()
|
|
|
|
def _handle_response(self, response: dict[str, str] | None) -> bool:
|
|
"""Render the assistant reply and re-enable the entry."""
|
|
self._set_input_enabled(True)
|
|
|
|
if not response:
|
|
self._append_message(
|
|
"assistant",
|
|
"The model returned an empty response.",
|
|
persist=True,
|
|
)
|
|
return False
|
|
|
|
role = response.get("role", "assistant")
|
|
content = response.get("content") or ""
|
|
if not content:
|
|
content = "[No content received from Ollama]"
|
|
|
|
self._append_message(role, content, persist=True)
|
|
return False
|