feat chat history

This commit is contained in:
fsylvain committed 2026-09-19 23:02:04 +02:00
1 parent 48c9456223
commit 1648b44189
2 files changed
+250 -2

No files matched your search

+78
View File
@@ -2,7 +2,10 @@ import json
import os import os
import re import re
import time import time
import uuid
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# CUDA 13.x moved its runtime DLLs to bin/x64, but llama_cpp's loader only # 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 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]]: def build_messages(history: list[tuple[str, str]], question: str) -> list[dict[str, str]]:
messages = [{"role": "system", "content": SYSTEM_PROMPT}] messages = [{"role": "system", "content": SYSTEM_PROMPT}]
for user_turn, assistant_turn in history[-MAX_HISTORY_TURNS:]: for user_turn, assistant_turn in history[-MAX_HISTORY_TURNS:]:
+172 -2
View File
@@ -31,7 +31,11 @@ from PyQt6.QtWidgets import (
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QListWidget,
QListWidgetItem,
QMainWindow, QMainWindow,
QMenu,
QMessageBox,
QPushButton, QPushButton,
QTextBrowser, QTextBrowser,
QVBoxLayout, QVBoxLayout,
@@ -113,6 +117,44 @@ QWidget {{
color: {TEXT_PRIMARY}; 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 {{ #badge {{
background-color: {BG_ELEVATED}; background-color: {BG_ELEVATED};
border: 1px solid {BORDER}; border: 1px solid {BORDER};
@@ -415,7 +457,11 @@ class ChatWindow(QMainWindow):
self.resize(900, 700) self.resize(900, 700)
self._llm = None 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._pending_question = ""
self._ask_thread: AskThread | None = None self._ask_thread: AskThread | None = None
# Position in chat_view's document where the in-progress streamed # Position in chat_view's document where the in-progress streamed
@@ -442,9 +488,38 @@ class ChatWindow(QMainWindow):
central = QWidget() central = QWidget()
self.setCentralWidget(central) 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.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(14) layout.setSpacing(14)
root_layout.addWidget(content, 1)
header = QWidget() header = QWidget()
header.setObjectName("header") header.setObjectName("header")
@@ -505,6 +580,10 @@ class ChatWindow(QMainWindow):
layout.addLayout(input_row) 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._init_gpu_monitor()
self.statusBar().showMessage("Loading model...") self.statusBar().showMessage("Loading model...")
@@ -555,9 +634,93 @@ class ChatWindow(QMainWindow):
def _set_input_enabled(self, enabled: bool) -> None: def _set_input_enabled(self, enabled: bool) -> None:
self.input_box.setEnabled(enabled) self.input_box.setEnabled(enabled)
self.send_button.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: if enabled:
self.input_box.setFocus() 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: def _on_model_loaded(self, llm) -> None:
self._llm = llm self._llm = llm
self._n_ctx = llm.n_ctx() self._n_ctx = llm.n_ctx()
@@ -743,6 +906,13 @@ class ChatWindow(QMainWindow):
self._discard_stream_card() self._discard_stream_card()
self._append_assistant(answer) self._append_assistant(answer)
self._history.append((self._pending_question, answer.strip())) 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.statusBar().showMessage("Ready.")
self._ask_thread = None self._ask_thread = None
self._set_input_enabled(True) self._set_input_enabled(True)