From 1648b44189453984d2253bc81f12a98c34ad9a3b Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sat, 19 Sep 2026 23:02:04 +0200 Subject: [PATCH] feat chat history --- chatbot.py | 78 ++++++++++++++++++++++++ gui.py | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 250 insertions(+), 2 deletions(-) diff --git a/chatbot.py b/chatbot.py index f147476..ed754d4 100644 --- a/chatbot.py +++ b/chatbot.py @@ -2,7 +2,10 @@ import json import os import re import time +import uuid from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime, timezone from pathlib import Path # CUDA 13.x moved its runtime DLLs to bin/x64, but llama_cpp's loader only @@ -105,6 +108,81 @@ def extract_tool_calls(text: str) -> list[dict]: return calls +# Chat sessions are persisted outside the repo, one JSON file per session, so +# history survives closing the app and isn't lost on an app update/reinstall. +CHATS_DIR = Path.home() / ".dev_assist_chatbot" / "chats" + +# Placeholder title for a session that hasn't received its first message yet; +# used to detect when a title still needs to be derived from that message. +DEFAULT_TITLE = "New chat" + + +@dataclass +class ChatSession: + id: str + title: str + created_at: str + updated_at: str + history: list[tuple[str, str]] = field(default_factory=list) + + +def new_session() -> ChatSession: + now = datetime.now(timezone.utc).isoformat() + return ChatSession(id=uuid.uuid4().hex, title=DEFAULT_TITLE, created_at=now, updated_at=now) + + +def derive_title(question: str, max_len: int = 40) -> str: + """First line of a session's opening question, trimmed to max_len - used + as its display title once it gets its first message.""" + title = question.strip().splitlines()[0] + return title if len(title) <= max_len else title[: max_len - 1].rstrip() + "…" + + +def _session_path(session_id: str) -> Path: + return CHATS_DIR / f"{session_id}.json" + + +def save_session(session: ChatSession) -> None: + session.updated_at = datetime.now(timezone.utc).isoformat() + CHATS_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "id": session.id, + "title": session.title, + "created_at": session.created_at, + "updated_at": session.updated_at, + "history": [[question, answer] for question, answer in session.history], + } + _session_path(session.id).write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def delete_session(session_id: str) -> None: + _session_path(session_id).unlink(missing_ok=True) + + +def list_sessions() -> list[ChatSession]: + """All persisted chat sessions, most recently updated first. A session + file that fails to parse is skipped rather than failing the whole list.""" + if not CHATS_DIR.is_dir(): + return [] + sessions = [] + for path in CHATS_DIR.glob("*.json"): + try: + data = json.loads(path.read_text(encoding="utf-8")) + sessions.append( + ChatSession( + id=data["id"], + title=data["title"], + created_at=data["created_at"], + updated_at=data["updated_at"], + history=[tuple(turn) for turn in data["history"]], + ) + ) + except (json.JSONDecodeError, OSError, KeyError, TypeError): + continue + sessions.sort(key=lambda s: s.updated_at, reverse=True) + return sessions + + def build_messages(history: list[tuple[str, str]], question: str) -> list[dict[str, str]]: messages = [{"role": "system", "content": SYSTEM_PROMPT}] for user_turn, assistant_turn in history[-MAX_HISTORY_TURNS:]: diff --git a/gui.py b/gui.py index 16aa0b2..128c82e 100644 --- a/gui.py +++ b/gui.py @@ -31,7 +31,11 @@ from PyQt6.QtWidgets import ( QHBoxLayout, QLabel, QLineEdit, + QListWidget, + QListWidgetItem, QMainWindow, + QMenu, + QMessageBox, QPushButton, QTextBrowser, QVBoxLayout, @@ -113,6 +117,44 @@ QWidget {{ color: {TEXT_PRIMARY}; }} +#sidebar {{ + background-color: {BG_APP}; + border-right: 1px solid {BORDER}; +}} + +QPushButton#newChatButton {{ + background-color: {BG_ELEVATED}; + border: 1px solid {BORDER}; + padding: 8px 12px; + color: {TEXT_PRIMARY}; + font-weight: 600; + text-align: left; +}} + +QPushButton#newChatButton:hover {{ + border: 1px solid {ACCENT}; +}} + +QListWidget#sessionList {{ + background-color: transparent; + border: none; + outline: none; +}} + +QListWidget#sessionList::item {{ + padding: 8px 10px; + color: {TEXT_SECONDARY}; +}} + +QListWidget#sessionList::item:selected {{ + background-color: {BG_ELEVATED}; + color: {TEXT_PRIMARY}; +}} + +QListWidget#sessionList::item:hover {{ + background-color: rgba(191, 192, 192, 0.08); +}} + #badge {{ background-color: {BG_ELEVATED}; border: 1px solid {BORDER}; @@ -415,7 +457,11 @@ class ChatWindow(QMainWindow): self.resize(900, 700) self._llm = None - self._history: list[tuple[str, str]] = [] + self._sessions: list[chatbot.ChatSession] = chatbot.list_sessions() + self._current_session: chatbot.ChatSession = ( + self._sessions[0] if self._sessions else chatbot.new_session() + ) + self._history: list[tuple[str, str]] = list(self._current_session.history) self._pending_question = "" self._ask_thread: AskThread | None = None # Position in chat_view's document where the in-progress streamed @@ -442,9 +488,38 @@ class ChatWindow(QMainWindow): central = QWidget() self.setCentralWidget(central) - layout = QVBoxLayout(central) + root_layout = QHBoxLayout(central) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.setSpacing(0) + + sidebar = QWidget() + sidebar.setObjectName("sidebar") + sidebar.setFixedWidth(220) + sidebar_layout = QVBoxLayout(sidebar) + sidebar_layout.setContentsMargins(12, 16, 12, 16) + sidebar_layout.setSpacing(10) + + self.new_chat_button = QPushButton("+ New Chat") + self.new_chat_button.setObjectName("newChatButton") + self.new_chat_button.setMinimumHeight(36) + self.new_chat_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.new_chat_button.clicked.connect(self._on_new_chat) + sidebar_layout.addWidget(self.new_chat_button) + + self.session_list = QListWidget() + self.session_list.setObjectName("sessionList") + self.session_list.itemClicked.connect(self._on_session_clicked) + self.session_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.session_list.customContextMenuRequested.connect(self._on_session_context_menu) + sidebar_layout.addWidget(self.session_list, 1) + + root_layout.addWidget(sidebar) + + content = QWidget() + layout = QVBoxLayout(content) layout.setContentsMargins(20, 16, 20, 16) layout.setSpacing(14) + root_layout.addWidget(content, 1) header = QWidget() header.setObjectName("header") @@ -505,6 +580,10 @@ class ChatWindow(QMainWindow): layout.addLayout(input_row) + self._refresh_session_list() + if self._current_session.history: + self._render_session_history(self._current_session) + self._init_gpu_monitor() self.statusBar().showMessage("Loading model...") @@ -555,9 +634,93 @@ class ChatWindow(QMainWindow): def _set_input_enabled(self, enabled: bool) -> None: self.input_box.setEnabled(enabled) self.send_button.setEnabled(enabled) + # Switching/creating/deleting a chat while a generation is streaming + # into chat_view would race the in-progress cursors, so the sidebar + # is only usable while idle, same as the input box. + self.new_chat_button.setEnabled(enabled) + self.session_list.setEnabled(enabled) if enabled: self.input_box.setFocus() + def _refresh_session_list(self) -> None: + self.session_list.blockSignals(True) + self.session_list.clear() + for session in self._sessions: + item = QListWidgetItem(session.title) + item.setData(Qt.ItemDataRole.UserRole, session.id) + self.session_list.addItem(item) + if session.id == self._current_session.id: + self.session_list.setCurrentItem(item) + self.session_list.blockSignals(False) + + def _render_session_history(self, session: "chatbot.ChatSession") -> None: + self.chat_view.clear() + for question, answer in session.history: + self._append_user(question) + self._append_assistant(answer) + + def _on_new_chat(self) -> None: + if self._ask_thread is not None: + return + if not self._current_session.history: + return # already on an empty, unsaved chat + self._current_session = chatbot.new_session() + self._history = [] + self.chat_view.clear() + self._append_system("Dev assist chatbot ready. Ask a development question.") + self.session_list.clearSelection() + self.statusBar().showMessage("Ready.") + + def _on_session_clicked(self, item: QListWidgetItem) -> None: + if self._ask_thread is not None: + return + session_id = item.data(Qt.ItemDataRole.UserRole) + if session_id == self._current_session.id: + return + session = next((s for s in self._sessions if s.id == session_id), None) + if session is None: + return + self._current_session = session + self._history = list(session.history) + self._render_session_history(session) + self.statusBar().showMessage("Ready.") + + def _on_session_context_menu(self, pos) -> None: + item = self.session_list.itemAt(pos) + if item is None or self._ask_thread is not None: + return + session_id = item.data(Qt.ItemDataRole.UserRole) + menu = QMenu(self) + delete_action = menu.addAction("Delete chat") + action = menu.exec(self.session_list.mapToGlobal(pos)) + if action == delete_action: + self._delete_session(session_id) + + def _delete_session(self, session_id: str) -> None: + reply = QMessageBox.question( + self, + "Delete chat", + "Delete this chat? This can't be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + chatbot.delete_session(session_id) + self._sessions = [s for s in self._sessions if s.id != session_id] + if self._current_session.id == session_id: + if self._sessions: + self._current_session = self._sessions[0] + else: + self._current_session = chatbot.new_session() + self._history = list(self._current_session.history) + self.chat_view.clear() + if self._current_session.history: + self._render_session_history(self._current_session) + else: + self._append_system("Dev assist chatbot ready. Ask a development question.") + self._refresh_session_list() + def _on_model_loaded(self, llm) -> None: self._llm = llm self._n_ctx = llm.n_ctx() @@ -743,6 +906,13 @@ class ChatWindow(QMainWindow): self._discard_stream_card() self._append_assistant(answer) self._history.append((self._pending_question, answer.strip())) + self._current_session.history = self._history + if self._current_session.title == chatbot.DEFAULT_TITLE: + self._current_session.title = chatbot.derive_title(self._pending_question) + chatbot.save_session(self._current_session) + self._sessions = [s for s in self._sessions if s.id != self._current_session.id] + self._sessions.insert(0, self._current_session) + self._refresh_session_list() self.statusBar().showMessage("Ready.") self._ask_thread = None self._set_input_enabled(True)