feat: implement conversation state management and persistence, enhance sidebar UI

This commit is contained in:
Melvin Ragusa
2025-10-25 18:22:07 +02:00
parent f5afb00a5b
commit da57c43e69
5 changed files with 536 additions and 28 deletions

View File

@@ -2,36 +2,208 @@
from __future__ import annotations
import threading
from typing import Iterable
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
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):
"""Minimal window placeholder to confirm the GTK application starts."""
"""Layer-shell anchored window hosting the chat interface."""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.set_default_size(360, 640)
self.set_default_size(360, 720)
self.set_title("Niri AI Sidebar")
self.set_hide_on_close(False)
layout = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
layout.set_margin_top(24)
layout.set_margin_bottom(24)
layout.set_margin_start(24)
layout.set_margin_end(24)
self._conversation_manager = ConversationManager()
self._ollama_client = OllamaClient()
self._current_model = self._ollama_client.default_model
title = Gtk.Label(label="AI Sidebar")
title.set_halign(Gtk.Align.START)
title.get_style_context().add_class("title-1")
self._setup_layer_shell()
self._build_ui()
self._populate_initial_messages()
message = Gtk.Label(
label="GTK app is running. Replace this view with the chat interface."
# ------------------------------------------------------------------ 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
)
message.set_wrap(True)
message.set_halign(Gtk.Align.START)
Gtk4LayerShell.set_exclusive_zone(self, -1)
layout.append(title)
layout.append(message)
self.set_child(layout)
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