better chat list view + anchor to bottom. sqlite db for chats + LIFO-ordered

This commit is contained in:
2026-08-23 16:37:11 +02:00
parent 37c6a9986e
commit aae3757daf
36 changed files with 2653 additions and 1459 deletions
+117 -62
View File
@@ -1,6 +1,6 @@
#include "chatstore.hpp"
#include "chat.hpp"
#include "llmclient.hpp"
#include "message.hpp"
#include <QDateTime>
@@ -15,7 +15,7 @@
#include <algorithm>
namespace ZShell {
namespace ZShell::llm {
ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
@@ -67,38 +67,42 @@ void ChatStore::openDb() {
")"));
}
{
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"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) "
"ON DELETE CASCADE,\n"
" role TEXT NOT NULL,\n"
" timestamp INTEGER NOT NULL\n"
")"));
}
{
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"
"CREATE TABLE IF NOT EXISTS generations (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" message_id INTEGER NOT NULL REFERENCES messages "
"(id) ON DELETE CASCADE,\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"
" content_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" is_active INTEGER NOT NULL DEFAULT 1\n"
")"));
}
{
QSqlQuery query(db);
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_messages_session "
"ON messages (session_id)"));
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_generations_message "
"ON generations (message_id)"));
}
}
int ChatStore::count() const {
@@ -121,7 +125,7 @@ ChatSession* ChatStore::at(int index) const {
ChatSession* ChatStore::insert(int index) {
const qint64 now = QDateTime::currentMSecsSinceEpoch();
const QString id = QString::number(now);
const QString id = QUuid::createUuid().toString();
{
QSqlQuery query(db());
query.prepare(
@@ -136,8 +140,7 @@ ChatSession* ChatStore::insert(int index) {
}
auto* session = new ChatSession(id, this);
session->setMeta(QString(), now, now, 0);
const int pos =
index >= 0 && index <= m_sessions.size() ? index : 0;
const int pos = index >= 0 && index <= m_sessions.size() ? index : 0;
m_sessions.insert(pos, session);
Q_EMIT countChanged();
Q_EMIT valuesChanged();
@@ -155,12 +158,8 @@ void ChatStore::remove(ChatSession* chat) {
void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
if (auto* chat = qobject_cast<Chat*>(parent()))
if (chat->m_active == session) {
chat->stop();
chat->endStream();
}
const QList<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session);
{
QSqlQuery query(db());
query.prepare("DELETE FROM sessions WHERE id = :id");
@@ -174,7 +173,7 @@ void ChatStore::removeSession(ChatSession* session) {
void ChatStore::move(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 ||
to >= m_sessions.size() || from == to)
to >= m_sessions.size() || from == to)
return;
m_sessions.move(from, to);
Q_EMIT valuesChanged();
@@ -193,6 +192,10 @@ ChatSession* ChatStore::sessionById(const QString& id) {
return nullptr;
}
void ChatStore::setLlmClient(LlmClient* client) {
m_llmClient = client;
}
void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
@@ -243,30 +246,58 @@ bool ChatStore::saveSession(ChatSession* session) {
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(
QSqlQuery messageInsert(handle);
ok = messageInsert.prepare(
"INSERT INTO messages (session_id, role, timestamp) "
"VALUES (:id, :role, :timestamp)");
QSqlQuery generationInsert(handle);
ok = ok && generationInsert.prepare(
"INSERT INTO generations (message_id, content, reasoning, "
"timestamp, reasoning_elapsed_ms, content_elapsed_ms, "
"is_active) VALUES (:mid, :content, :reasoning, :timestamp, "
":reasoning_elapsed_ms, :content_elapsed_ms, :is_active)");
// The model holds messages most recent first; the database keeps
// natural rowid order, so iterate from the oldest row up.
const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(row);
messageInsert.bindValue(":id", session->id());
messageInsert.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()) {
messageInsert.bindValue(":timestamp", message->timestamp());
if (!messageInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id << "insert failed:"
<< insert.lastError().text();
qWarning() << "ChatStore: saveSession" << id
<< "message insert failed:"
<< messageInsert.lastError().text();
break;
}
const int messageId = messageInsert.lastInsertId().toInt();
for (int i = 0; ok && i < message->generationCount(); ++i) {
const auto* generation = message->generation(i);
generationInsert.bindValue(":mid", messageId);
generationInsert.bindValue(":content", generation->content());
generationInsert.bindValue(
":reasoning", generation->reasoning());
generationInsert.bindValue(":timestamp", generation->timestamp());
generationInsert.bindValue(
":reasoning_elapsed_ms", generation->reasoningElapsedMs());
generationInsert.bindValue(
":content_elapsed_ms", generation->contentElapsedMs());
generationInsert.bindValue(
":is_active",
i == message->activeGenerationIndex() ? 1 : 0);
if (!generationInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "generation insert failed:"
<< generationInsert.lastError().text();
break;
}
}
}
}
if (!ok || !handle.commit()) {
@@ -280,28 +311,52 @@ bool ChatStore::saveSession(ChatSession* session) {
}
void ChatStore::loadMessagesInto(ChatSession* session) {
// Newest first so the model receives rows in display order.
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");
"SELECT id, role, timestamp FROM messages WHERE session_id = :id "
"ORDER BY rowid DESC");
query.bindValue(":id", session->id());
if (!query.exec()) {
qWarning() << "ChatStore: failed to load messages for" << session->id()
<< ":" << query.lastError().text();
return;
}
auto* model = session->messagesModel();
QList<ChatMessage*> messages;
while (query.next()) {
auto* message = new ChatMessage(
query.value(0).toString() == QLatin1String("user")
const int messageId = query.value(0).toInt();
auto* message = model->createMessage(
query.value(1).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());
query.value(2).toLongLong());
QSqlQuery generationQuery(db());
generationQuery.prepare(
"SELECT content, reasoning, timestamp, reasoning_elapsed_ms, "
"content_elapsed_ms, is_active FROM generations "
"WHERE message_id = :mid ORDER BY rowid");
generationQuery.bindValue(":mid", messageId);
int activeIndex = 0;
if (generationQuery.exec()) {
int index = 0;
while (generationQuery.next()) {
message->addGeneration(
generationQuery.value(2).toLongLong(),
generationQuery.value(0).toString(),
generationQuery.value(1).toString(),
generationQuery.value(3).toLongLong(),
generationQuery.value(4).toLongLong());
if (generationQuery.value(5).toInt() != 0)
activeIndex = index;
++index;
}
} else {
qWarning() << "ChatStore: failed to load generations for message"
<< messageId << ":"
<< generationQuery.lastError().text();
}
message->setActiveGeneration(activeIndex);
messages.append(message);
}
session->adoptMessages(messages);
@@ -351,4 +406,4 @@ void ChatStore::notify(const QList<ChatSession*>& before) {
Q_EMIT valuesChanged();
}
} // namespace ZShell
} // namespace ZShell::llm