add markdown parsing + latex + tree-sitter highlighting for codeblocks in llm responses

This commit is contained in:
2026-08-24 23:55:51 +02:00
parent aae3757daf
commit e18875a752
53 changed files with 5043 additions and 618 deletions
+137 -27
View File
@@ -2,6 +2,7 @@
#include "llmclient.hpp"
#include "message.hpp"
#include "segment.hpp"
#include <QDateTime>
#include <QDebug>
@@ -17,6 +18,38 @@
namespace ZShell::llm {
namespace {
QString segmentTypeName(LlmSegment::Type type) {
switch (type) {
case LlmSegment::Type::Reasoning:
return QStringLiteral("reasoning");
case LlmSegment::Type::ToolCall:
return QStringLiteral("tool_call");
case LlmSegment::Type::Content:
return QStringLiteral("content");
}
return QStringLiteral("reasoning");
}
LlmSegment::Type segmentTypeFromName(const QString& name) {
if (name == QLatin1String("tool_call"))
return LlmSegment::Type::ToolCall;
if (name == QLatin1String("content"))
return LlmSegment::Type::Content;
return LlmSegment::Type::Reasoning;
}
// A null QString binds as SQL NULL, which violates the NOT NULL columns;
// DEFAULT only applies to omitted columns, not explicit NULLs.
QString sqlText(const QString& value) {
if (value.isNull())
return QStringLiteral("");
return value;
}
} // namespace
ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
openDb();
@@ -86,14 +119,29 @@ void ChatStore::openDb() {
" 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"
" is_active INTEGER NOT NULL DEFAULT 1\n"
")"));
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS segments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" generation_id INTEGER NOT NULL REFERENCES generations "
"(id) ON DELETE CASCADE,\n"
" type TEXT NOT NULL,\n"
" text TEXT NOT NULL DEFAULT '',\n"
" name TEXT NOT NULL DEFAULT '',\n"
" tool_call_id TEXT NOT NULL DEFAULT '',\n"
" arguments TEXT NOT NULL DEFAULT '',\n"
" result TEXT NOT NULL DEFAULT '',\n"
" status INTEGER NOT NULL DEFAULT 0,\n"
" elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" timestamp INTEGER NOT NULL\n"
")"));
}
{
QSqlQuery query(db);
query.exec(QStringLiteral(
@@ -102,6 +150,9 @@ void ChatStore::openDb() {
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_generations_message "
"ON generations (message_id)"));
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_segments_generation "
"ON segments (generation_id)"));
}
}
@@ -212,8 +263,8 @@ void ChatStore::saveMeta(ChatSession* session) {
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(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":id", session->id());
if (!query.exec())
qWarning() << "ChatStore: failed to save meta for" << session->id()
@@ -234,7 +285,7 @@ bool ChatStore::saveSession(ChatSession* session) {
query.prepare(
"UPDATE sessions SET title = :title, updated_at = :updated_at "
"WHERE id = :id");
query.bindValue(":title", session->title());
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":updated_at", session->updatedAtMs());
query.bindValue(":id", session->id());
ok = query.exec();
@@ -252,10 +303,15 @@ bool ChatStore::saveSession(ChatSession* session) {
"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)");
"INSERT INTO generations (message_id, timestamp, is_active) "
"VALUES (:mid, :timestamp, :is_active)");
QSqlQuery segmentInsert(handle);
ok = ok && segmentInsert.prepare(
"INSERT INTO segments (generation_id, type, text, name, "
"tool_call_id, arguments, result, status, elapsed_ms, "
"timestamp) VALUES (:gid, :type, :text, :name, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
":timestamp)");
// 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();
@@ -279,14 +335,8 @@ bool ChatStore::saveSession(ChatSession* session) {
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());
":timestamp", generation->timestamp());
generationInsert.bindValue(
":is_active",
i == message->activeGenerationIndex() ? 1 : 0);
@@ -297,6 +347,33 @@ bool ChatStore::saveSession(ChatSession* session) {
<< generationInsert.lastError().text();
break;
}
const int generationId =
generationInsert.lastInsertId().toInt();
for (const auto* segment : generation->segments()) {
segmentInsert.bindValue(":gid", generationId);
segmentInsert.bindValue(
":type", segmentTypeName(segment->type()));
segmentInsert.bindValue(":text", sqlText(segment->text()));
segmentInsert.bindValue(":name", sqlText(segment->name()));
segmentInsert.bindValue(
":tool_call_id", sqlText(segment->toolCallId()));
segmentInsert.bindValue(
":arguments", sqlText(segment->arguments()));
segmentInsert.bindValue(":result", sqlText(segment->result()));
segmentInsert.bindValue(
":status", static_cast<int>(segment->status()));
segmentInsert.bindValue(
":elapsed_ms", segment->elapsedMs());
segmentInsert.bindValue(
":timestamp", segment->timestamp());
if (!segmentInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "segment insert failed:"
<< segmentInsert.lastError().text();
break;
}
}
}
}
}
@@ -333,21 +410,54 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
query.value(2).toLongLong());
QSqlQuery generationQuery(db());
generationQuery.prepare(
"SELECT content, reasoning, timestamp, reasoning_elapsed_ms, "
"content_elapsed_ms, is_active FROM generations "
"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()) {
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)
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);
}
} else {
qWarning() << "ChatStore: failed to load segments for "
<< "generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
}
if (generationQuery.value(2).toInt() != 0)
activeIndex = index;
++index;
}