async parsing + tex caching
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user