feat chat history
This commit is contained in:
1 parent
48c9456223
commit
1648b44189
2 files changed
+250
-2
No files matched your search
+78
@@ -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:]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user