better chat list view + anchor to bottom. sqlite db for chats + LIFO-ordered
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user