add removal of chat sessions, auto title + icon, proper stick-to-bottom as response is streamed

This commit is contained in:
2026-08-20 17:40:33 +02:00
parent 47bab21e22
commit 9657e5092a
12 changed files with 776 additions and 273 deletions
+230 -127
View File
@@ -1,54 +1,104 @@
#include "chatstore.hpp"
#include "chat.hpp"
#include "message.hpp"
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFileInfoList>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
#include <QStandardPaths>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QUuid>
#include <algorithm>
namespace ZShell {
ChatStore::ChatStore(QObject* parent) : QObject(parent) {
ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
openDb();
load();
}
QString ChatStore::dir() {
const QString base =
QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) +
QStringLiteral("/zshell");
return base + QStringLiteral("/chats");
ChatStore::~ChatStore() {
if (m_connectionName.isEmpty())
return;
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
db.close();
QSqlDatabase::removeDatabase(m_connectionName);
}
QString ChatStore::pathFor(const QString& id) {
return dir() + QStringLiteral("/") + id + QStringLiteral(".json");
QSqlDatabase ChatStore::db() const {
return QSqlDatabase::database(m_connectionName);
}
bool ChatStore::writeFile(const QString& path, const QJsonObject& doc) {
void ChatStore::openDb() {
const QString path =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) +
QStringLiteral("/zshell/chats.sqlite");
QDir().mkpath(QFileInfo(path).absolutePath());
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly)) {
qWarning() << "ChatStore: failed to open" << path << "for writing:"
<< file.errorString();
return false;
QSqlDatabase db =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
db.setDatabaseName(path);
if (!db.open()) {
qWarning() << "ChatStore: failed to open database" << path << ":"
<< db.lastError().text();
return;
}
if (file.write(QJsonDocument(doc).toJson(QJsonDocument::Indented)) < 0 ||
!file.commit()) {
qWarning() << "ChatStore: failed to write" << path << ":"
<< file.errorString();
return false;
{
QSqlQuery pragma(db);
pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS sessions (\n"
" id TEXT PRIMARY KEY,\n"
" title TEXT NOT NULL DEFAULT '',\n"
" icon TEXT NOT NULL DEFAULT '',\n"
" created_at INTEGER NOT NULL,\n"
" updated_at INTEGER NOT NULL,\n"
" pinned INTEGER NOT NULL DEFAULT 0\n"
")"));
}
{
QSqlQuery info(db);
bool hasIcon = false;
if (info.exec(QStringLiteral("PRAGMA table_info(sessions)")))
while (info.next())
if (info.value(1).toString() == QLatin1String("icon")) {
hasIcon = true;
break;
}
if (!hasIcon) {
QSqlQuery alter(db);
if (!alter.exec(QStringLiteral(
"ALTER TABLE sessions ADD COLUMN icon TEXT NOT NULL "
"DEFAULT ''")))
qWarning() << "ChatStore: failed to add icon column:"
<< alter.lastError().text();
}
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS messages (\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE "
"CASCADE,\n"
" role TEXT NOT NULL,\n"
" content TEXT,\n"
" reasoning TEXT,\n"
" timestamp INTEGER NOT NULL,\n"
" reasoning_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" content_elapsed_ms INTEGER NOT NULL DEFAULT 0\n"
")"));
}
return true;
}
int ChatStore::count() const {
@@ -71,8 +121,20 @@ ChatSession* ChatStore::at(int index) const {
ChatSession* ChatStore::insert(int index) {
const qint64 now = QDateTime::currentMSecsSinceEpoch();
auto* session = new ChatSession(QString::number(now), this);
session->setPath(pathFor(session->id()));
const QString id = QString::number(now);
{
QSqlQuery query(db());
query.prepare(
"INSERT INTO sessions (id, title, created_at, updated_at) "
"VALUES (:id, '', :created_at, :updated_at)");
query.bindValue(":id", id);
query.bindValue(":created_at", now);
query.bindValue(":updated_at", now);
if (!query.exec())
qWarning() << "ChatStore: failed to insert session" << id << ":"
<< query.lastError().text();
}
auto* session = new ChatSession(id, this);
session->setMeta(QString(), now, now, 0);
const int pos =
index >= 0 && index <= m_sessions.size() ? index : 0;
@@ -93,15 +155,18 @@ void ChatStore::remove(ChatSession* chat) {
void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
if (isStreaming(session)) {
qWarning() << "ChatStore: cannot remove a chat that is streaming";
return;
}
const QList<ChatSession*> before = m_sessions;
QFile::remove(session->path());
if (auto* chat = qobject_cast<Chat*>(parent()))
if (chat->m_active == session)
chat->m_active = nullptr;
if (chat->m_active == session) {
chat->stop();
chat->endStream();
}
const QList<ChatSession*> before = m_sessions;
{
QSqlQuery query(db());
query.prepare("DELETE FROM sessions WHERE id = :id");
query.bindValue(":id", session->id());
query.exec();
}
m_sessions.removeOne(session);
session->deleteLater();
notify(before);
@@ -116,18 +181,9 @@ void ChatStore::move(int from, int to) {
}
void ChatStore::clear() {
const QList<ChatSession*> before = m_sessions;
QList<ChatSession*> keep;
for (ChatSession* session : std::as_const(m_sessions)) {
if (isStreaming(session)) {
keep.append(session);
continue;
}
QFile::remove(session->path());
session->deleteLater();
}
m_sessions = keep;
notify(before);
const QList<ChatSession*> sessions = m_sessions;
for (ChatSession* session : sessions)
removeSession(session);
}
ChatSession* ChatStore::sessionById(const QString& id) {
@@ -141,14 +197,134 @@ void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
if (!writeFile(session->path(), session->document()))
session->ensureLoaded();
if (!saveSession(session))
return;
sortAndNotify();
}
bool ChatStore::isStreaming(ChatSession* session) const {
const auto* chat = qobject_cast<const Chat*>(parent());
return chat && chat->streamingSession() == session;
void ChatStore::saveMeta(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
QSqlQuery query(db());
query.prepare("UPDATE sessions SET title = :title, icon = :icon "
"WHERE id = :id");
query.bindValue(":title", session->title());
query.bindValue(":icon", session->icon());
query.bindValue(":id", session->id());
if (!query.exec())
qWarning() << "ChatStore: failed to save meta for" << session->id()
<< ":" << query.lastError().text();
}
bool ChatStore::saveSession(ChatSession* session) {
const QString id = session->id();
QSqlDatabase handle = db();
if (!handle.transaction()) {
qWarning() << "ChatStore: failed to begin transaction:"
<< handle.lastError().text();
return false;
}
bool ok = true;
{
QSqlQuery query(handle);
query.prepare(
"UPDATE sessions SET title = :title, updated_at = :updated_at "
"WHERE id = :id");
query.bindValue(":title", session->title());
query.bindValue(":updated_at", session->updatedAtMs());
query.bindValue(":id", session->id());
ok = query.exec();
}
if (ok) {
QSqlQuery query(handle);
query.prepare("DELETE FROM messages WHERE session_id = :id");
query.bindValue(":id", session->id());
ok = query.exec();
}
if (ok) {
QSqlQuery insert(handle);
ok = insert.prepare(
"INSERT INTO messages (session_id, role, content, reasoning, "
"timestamp, reasoning_elapsed_ms, content_elapsed_ms) "
"VALUES (:id, :role, :content, :reasoning, :timestamp, "
":reasoning_elapsed_ms, :content_elapsed_ms)");
for (const auto* message : session->messages()) {
insert.bindValue(":id", session->id());
insert.bindValue(
":role",
message->role() == ChatMessage::Role::User
? QStringLiteral("user")
: QStringLiteral("assistant"));
insert.bindValue(":content", message->content());
insert.bindValue(":reasoning", message->reasoning());
insert.bindValue(":timestamp", message->timestamp());
insert.bindValue(":reasoning_elapsed_ms", message->reasoningElapsedMs());
insert.bindValue(":content_elapsed_ms", message->contentElapsedMs());
if (!insert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id << "insert failed:"
<< insert.lastError().text();
break;
}
}
}
if (!ok || !handle.commit()) {
qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back";
handle.rollback();
ok = false;
qWarning() << "ChatStore: failed to save session" << session->id() << ":"
<< handle.lastError().text();
}
return ok;
}
void ChatStore::loadMessagesInto(ChatSession* session) {
QSqlQuery query(db());
query.prepare(
"SELECT role, content, reasoning, timestamp, reasoning_elapsed_ms, "
"content_elapsed_ms FROM messages WHERE session_id = :id ORDER BY rowid");
query.bindValue(":id", session->id());
if (!query.exec()) {
qWarning() << "ChatStore: failed to load messages for" << session->id()
<< ":" << query.lastError().text();
return;
}
QList<ChatMessage*> messages;
while (query.next()) {
auto* message = new ChatMessage(
query.value(0).toString() == QLatin1String("user")
? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
query.value(1).toString(),
query.value(3).toLongLong(),
session);
message->setReasoning(query.value(2).toString());
message->setElapsedMs(
query.value(4).toLongLong(), query.value(5).toLongLong());
messages.append(message);
}
session->adoptMessages(messages);
}
void ChatStore::load() {
QSqlQuery query(db());
query.exec(
"SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, "
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt "
"FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC");
while (query.next()) {
auto* session = new ChatSession(query.value(0).toString(), this);
session->setMeta(
query.value(1).toString(),
query.value(3).toLongLong(),
query.value(4).toLongLong(),
query.value(6).toInt());
session->setIcon(query.value(2).toString());
session->setPinned(query.value(5).toInt() != 0);
m_sessions.append(session);
}
sortAndNotify();
}
void ChatStore::sortAndNotify() {
@@ -157,6 +333,8 @@ void ChatStore::sortAndNotify() {
m_sessions.begin(),
m_sessions.end(),
[](const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned())
return a->pinned() > b->pinned();
return a->updatedAtMs() > b->updatedAtMs();
});
notify(before);
@@ -173,79 +351,4 @@ void ChatStore::notify(const QList<ChatSession*>& before) {
Q_EMIT valuesChanged();
}
void ChatStore::load() {
const QFileInfoList files = QDir(dir()).entryInfoList(
QStringList{QStringLiteral("*.json")},
QDir::Files,
QDir::Time);
for (const QFileInfo& info : files) {
QFile file(info.absoluteFilePath());
if (!file.open(QIODevice::ReadOnly))
continue;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close();
if (!doc.isObject())
continue;
const QJsonObject obj = doc.object();
auto* session =
new ChatSession(obj["id"].toString(info.completeBaseName()), this);
session->setPath(info.absoluteFilePath());
session->setMeta(
obj["title"].toString(),
obj["createdAt"].toVariant().toLongLong(),
obj["updatedAt"].toVariant().toLongLong(),
int(obj["messages"].toArray().size()));
m_sessions.append(session);
}
sortAndNotify();
migrateLegacy();
}
void ChatStore::migrateLegacy() {
const QString path = dir() + QStringLiteral("/chat.json");
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close();
const QJsonArray arr = doc.isArray() ? doc.array() : QJsonArray();
QString title;
qint64 first = 0;
qint64 last = 0;
QJsonArray messages;
for (const QJsonValue& value : arr) {
const QJsonObject msg = value.toObject();
const QString content = msg["content"].toString();
if (content.isEmpty())
continue;
if (first == 0)
first = msg["timestamp"].toVariant().toLongLong();
last = msg["timestamp"].toVariant().toLongLong();
if (title.isEmpty() && msg["role"].toString() == "user")
title = Chat::titleFrom(content);
messages.append(msg);
}
if (messages.isEmpty())
return;
const QString id = QString::number(QDateTime::currentMSecsSinceEpoch());
auto* session = new ChatSession(id, this);
session->setPath(pathFor(id));
QJsonObject out;
out[QStringLiteral("id")] = id;
out[QStringLiteral("title")] = title;
out[QStringLiteral("createdAt")] = first;
out[QStringLiteral("updatedAt")] = last;
out[QStringLiteral("messages")] = messages;
if (!writeFile(session->path(), out))
return;
QFile::remove(path);
session->setMeta(title, first, last, int(messages.size()));
m_sessions.append(session);
sortAndNotify();
}
} // namespace ZShell