210 lines
7.8 KiB
Python
210 lines
7.8 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)
|
|
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._setup_layer_shell()
|
|
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)
|
|
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)
|
|
|
|
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
|