async parsing + tex caching

This commit is contained in:
2026-08-26 02:06:10 +02:00
parent e18875a752
commit 3946541f87
32 changed files with 2730 additions and 489 deletions
+232 -83
View File
@@ -4,14 +4,18 @@
#include "message.hpp"
#include "segment.hpp"
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QPointer>
#include <QStandardPaths>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QThreadPool>
#include <QVector>
#include <QUuid>
#include <algorithm>
@@ -48,6 +52,33 @@ QString sqlText(const QString& value) {
return value;
}
// Plain data for one session's messages, fetched on a worker thread
// and turned into the QObject tree on the GUI thread. Messages are
// ordered as the model displays them (newest first).
struct SegmentRow {
QString type;
QString text;
QString name;
QString toolCallId;
QString arguments;
QString result;
int status = 0;
qint64 elapsedMs = 0;
qint64 timestamp = 0;
};
struct GenerationRow {
qint64 timestamp = 0;
bool active = false;
QVector<SegmentRow> segments;
};
struct MessageRow {
bool user = false;
qint64 timestamp = 0;
QVector<GenerationRow> generations;
};
} // namespace
ChatStore::ChatStore(QObject* parent)
@@ -69,16 +100,16 @@ QSqlDatabase ChatStore::db() const {
}
void ChatStore::openDb() {
const QString path =
m_dbPath =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) +
QStringLiteral("/zshell/chats.sqlite");
QDir().mkpath(QFileInfo(path).absolutePath());
QDir().mkpath(QFileInfo(m_dbPath).absolutePath());
QSqlDatabase db =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
db.setDatabaseName(path);
db.setDatabaseName(m_dbPath);
if (!db.open()) {
qWarning() << "ChatStore: failed to open database" << path << ":"
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
<< db.lastError().text();
return;
}
@@ -250,8 +281,13 @@ void ChatStore::setLlmClient(LlmClient* client) {
void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
if (!session->isLoaded()) {
// Saving now would persist an incomplete model and wipe the
// stored history; run it again when the load lands.
m_pendingPersists.insert(session);
return;
}
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
session->ensureLoaded();
if (!saveSession(session))
return;
sortAndNotify();
@@ -386,90 +422,203 @@ bool ChatStore::saveSession(ChatSession* session) {
}
return ok;
}
void ChatStore::loadMessagesInto(ChatSession* session) {
// Newest first so the model receives rows in display order.
QSqlQuery query(db());
query.prepare(
"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();
if (!session)
return;
}
auto* model = session->messagesModel();
QList<ChatMessage*> messages;
while (query.next()) {
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(2).toLongLong());
QSqlQuery generationQuery(db());
generationQuery.prepare(
"SELECT id, timestamp, 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()) {
auto* generation = message->addGeneration(
generationQuery.value(1).toLongLong());
QSqlQuery segmentQuery(db());
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, arguments, "
"result, status, elapsed_ms, timestamp FROM segments "
"WHERE generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue(
":gid", generationQuery.value(0).toInt());
if (segmentQuery.exec()) {
while (segmentQuery.next()) {
auto* segment = new LlmSegment(
segmentTypeFromName(
segmentQuery.value(0).toString()),
segmentQuery.value(8).toLongLong(),
generation);
segment->setText(
segmentQuery.value(1).toString());
segment->setName(
segmentQuery.value(2).toString());
segment->setToolCallId(
segmentQuery.value(3).toString());
segment->appendArguments(
segmentQuery.value(4).toString());
segment->setResult(
segmentQuery.value(5).toString());
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentQuery.value(6).toInt()));
segment->restore(
segmentQuery.value(7).toLongLong());
generation->addSegment(segment);
const QString sessionId = session->id();
const QString path = m_dbPath;
// SQL on a worker thread (its own connection; QSqlDatabase objects
// are thread-affine). Rows come back as plain data.
QThreadPool::globalInstance()->start(
[store = QPointer<ChatStore>(this),
session = QPointer<ChatSession>(session), sessionId, path]() {
QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString();
{
QSqlDatabase db = QSqlDatabase::addDatabase(
QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path);
if (db.open()) {
// Tolerate the GUI thread writing while we read.
QSqlQuery busy(db);
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
// Newest first so the model receives rows in
// display order.
QSqlQuery query(db);
query.prepare(
"SELECT id, role, timestamp FROM messages "
"WHERE session_id = :id ORDER BY rowid DESC");
query.bindValue(":id", sessionId);
if (!query.exec()) {
qWarning()
<< "ChatStore: failed to load messages for"
<< sessionId << ":"
<< query.lastError().text();
} else {
while (query.next()) {
const int messageId = query.value(0).toInt();
MessageRow message;
message.user =
query.value(1).toString() ==
QLatin1String("user");
message.timestamp = query.value(2).toLongLong();
QSqlQuery generationQuery(db);
generationQuery.prepare(
"SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid "
"ORDER BY rowid");
generationQuery.bindValue(":mid", messageId);
if (generationQuery.exec()) {
while (generationQuery.next()) {
GenerationRow generation;
generation.timestamp =
generationQuery.value(1).toLongLong();
generation.active =
generationQuery.value(2).toInt() != 0;
QSqlQuery segmentQuery(db);
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, "
"arguments, result, status, elapsed_ms, "
"timestamp FROM segments WHERE "
"generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue(
":gid",
generationQuery.value(0).toInt());
if (segmentQuery.exec()) {
while (segmentQuery.next()) {
SegmentRow segment;
segment.type =
segmentQuery.value(0).toString();
segment.text =
segmentQuery.value(1).toString();
segment.name =
segmentQuery.value(2).toString();
segment.toolCallId =
segmentQuery.value(3).toString();
segment.arguments =
segmentQuery.value(4).toString();
segment.result =
segmentQuery.value(5).toString();
segment.status =
segmentQuery.value(6).toInt();
segment.elapsedMs =
segmentQuery.value(7).toLongLong();
segment.timestamp =
segmentQuery.value(8).toLongLong();
generation.segments.append(segment);
}
} else {
qWarning()
<< "ChatStore: failed to load "
"segments for generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
}
message.generations.append(generation);
}
} else {
qWarning()
<< "ChatStore: failed to load generations "
"for message"
<< messageId << ":"
<< generationQuery.lastError().text();
}
rows.append(message);
}
}
db.close();
} else {
qWarning() << "ChatStore: failed to load segments for "
<< "generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
qWarning()
<< "ChatStore: failed to open database for load:"
<< db.lastError().text();
}
if (generationQuery.value(2).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);
// Remove only once every QSqlDatabase copy and query is gone;
// while any reference is alive Qt refuses the removal and the
// connection is left dangling in a broken state.
QSqlDatabase::removeDatabase(connName);
// Build the object tree on the GUI thread. Deliver through
// the app instance (never destroyed) and re-check the
// pointers there: posting to `store` from the pool thread
// would race with its destruction.
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[store, session, rows = std::move(rows)]() mutable {
ChatStore* st = store;
ChatSession* s = session;
if (!st || !s)
return;
// Rows fetched from disk; newest first.
auto* model = s->model();
if (!model)
return;
QList<ChatMessage*> messages;
for (const MessageRow& row : rows) {
auto* message = model->createMessage(
row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
row.timestamp);
int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow =
row.generations.at(i);
auto* generation =
message->addGeneration(generationRow.timestamp);
for (const SegmentRow& segmentRow :
generationRow.segments) {
auto* segment = new LlmSegment(
segmentTypeFromName(segmentRow.type),
segmentRow.timestamp,
generation);
segment->setText(segmentRow.text);
segment->setName(segmentRow.name);
segment->setToolCallId(segmentRow.toolCallId);
segment->appendArguments(segmentRow.arguments);
segment->setResult(segmentRow.result);
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentRow.status));
segment->restore(segmentRow.elapsedMs);
generation->addSegment(segment);
}
if (generationRow.active)
activeIndex = i;
}
message->setActiveGeneration(activeIndex);
messages.append(message);
}
// Rows added live while the load was in flight are
// newer than anything on disk; keep them in front.
if (model->rowCount() > 0) {
QList<ChatMessage*> live = messages;
for (int r = 0; r < model->rowCount(); ++r)
live.prepend(model->at(r));
messages = live;
}
if (!messages.isEmpty() || model->rowCount() > 0)
s->adoptMessages(messages);
// Mark loaded only once the model holds both the
// fetched history and the rows added live while the
// load ran, so a deferred startGeneration (triggered
// by loaded()) builds its context from the complete
// conversation.
s->markLoaded();
if (s->takeClearPending()) {
// Cleared while the load was in flight; drop
// everything now that the model is populated.
s->clear();
} else if (st->m_pendingPersists.remove(s)) {
st->persist(s);
}
},
Qt::QueuedConnection);
});
}
void ChatStore::load() {