From 52feb6006aefd568228cb2719bdd0915f6c76a4f Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 19:02:26 +0200 Subject: [PATCH] chore: format llm c files --- Plugins/ZShell/Llm/chat.cpp | 31 +- Plugins/ZShell/Llm/chat.hpp | 12 +- Plugins/ZShell/Llm/chatstore.cpp | 543 +++++++++++-------------- Plugins/ZShell/Llm/chatstore.hpp | 5 - Plugins/ZShell/Llm/codehighlighter.cpp | 1 - Plugins/ZShell/Llm/codehighlighter.hpp | 67 +-- Plugins/ZShell/Llm/generation.cpp | 120 ++---- Plugins/ZShell/Llm/generation.hpp | 33 +- Plugins/ZShell/Llm/llmclient.cpp | 392 +++++++----------- Plugins/ZShell/Llm/llmclient.hpp | 24 +- Plugins/ZShell/Llm/markdownblock.hpp | 9 +- Plugins/ZShell/Llm/markdownparser.cpp | 61 +-- Plugins/ZShell/Llm/markdownparser.hpp | 12 - Plugins/ZShell/Llm/mathtext.cpp | 222 +++++----- Plugins/ZShell/Llm/mathtext.hpp | 23 +- Plugins/ZShell/Llm/message.cpp | 23 +- Plugins/ZShell/Llm/message.hpp | 23 +- Plugins/ZShell/Llm/messagemodel.cpp | 6 +- Plugins/ZShell/Llm/messagemodel.hpp | 13 +- Plugins/ZShell/Llm/segment.cpp | 73 +--- Plugins/ZShell/Llm/segment.hpp | 27 +- Plugins/ZShell/Llm/session.cpp | 9 +- Plugins/ZShell/Llm/session.hpp | 7 - Plugins/ZShell/Llm/tool.cpp | 12 +- Plugins/ZShell/Llm/tool.hpp | 14 - Plugins/ZShell/Llm/webfetchtool.cpp | 160 ++++---- Plugins/ZShell/Llm/webfetchtool.hpp | 9 - 27 files changed, 741 insertions(+), 1190 deletions(-) diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index 6387699..b7adb9b 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -10,9 +10,10 @@ namespace ZShell::llm { Chat::Chat(QObject* parent) -: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) { - if (!config::Config::instance()) - new config::Config(); + : QObject(parent) + , m_store(new ChatStore(this)) + , m_client(new LlmClient(this)) { + if (!config::Config::instance()) new config::Config(); m_store->setLlmClient(m_client); m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); @@ -44,10 +45,8 @@ Chat::Chat(QObject* parent) } Q_EMIT busyChanged(); }); - connect( - m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); - connect( - m_client, &LlmClient::modelChanged, this, &Chat::modelChanged); + connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); + connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged); connect( m_client, &LlmClient::availableModelsChanged, @@ -88,7 +87,7 @@ Chat::Chat(QObject* parent) this, [this](ChatSession* session, const QString& title) { qInfo() << "Chat: applying generated title" << session->id() - << title << "(was" << session->title() << ")"; + << title << "(was" << session->title() << ")"; session->setTitle(title); m_store->saveMeta(session); }); @@ -97,8 +96,8 @@ Chat::Chat(QObject* parent) &LlmClient::iconSuggested, this, [this](ChatSession* session, const QString& icon) { - qInfo() << "Chat: applying generated icon" << session->id() - << icon << "(was" << session->icon() << ")"; + qInfo() << "Chat: applying generated icon" << session->id() << icon + << "(was" << session->icon() << ")"; session->setIcon(icon); m_store->saveMeta(session); }); @@ -141,8 +140,7 @@ QString Chat::streamingChatId() const { Chat* Chat::s_instance = nullptr; Chat* Chat::create(QQmlEngine*, QJSEngine*) { - if (!s_instance) - s_instance = new Chat(); + if (!s_instance) s_instance = new Chat(); return s_instance; } @@ -151,8 +149,7 @@ void Chat::stop() { } void Chat::dismissError() { - if (m_lastError.isEmpty()) - return; + if (m_lastError.isEmpty()) return; m_lastError.clear(); Q_EMIT lastErrorChanged(); } @@ -162,11 +159,9 @@ void Chat::refreshModels() { } void Chat::selectModel(const QString& id) { - if (id.isEmpty()) - return; + if (id.isEmpty()) return; m_client->setModel(id); - if (auto* config = config::Config::instance()) - config->llm()->set_model(id); + if (auto* config = config::Config::instance()) config->llm()->set_model(id); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index bbafabe..276b4a3 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -25,12 +25,18 @@ class Chat : public QObject { Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged) Q_PROPERTY(QString model READ model NOTIFY modelChanged) - Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) + Q_PROPERTY( + QStringList availableModels READ availableModels NOTIFY + availableModelsChanged) Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) - Q_PROPERTY(bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY toolsEnabledChanged) + Q_PROPERTY( + bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY + toolsEnabledChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) - Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged) + Q_PROPERTY( + QString streamingChatId READ streamingChatId NOTIFY + streamingChatIdChanged) public: explicit Chat(QObject* parent = nullptr); diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 73936b2..3d9056f 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -37,24 +37,16 @@ QString segmentTypeName(LlmSegment::Type type) { } LlmSegment::Type segmentTypeFromName(const QString& name) { - if (name == QLatin1String("tool_call")) - return LlmSegment::Type::ToolCall; - if (name == QLatin1String("content")) - return LlmSegment::Type::Content; + 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(""); + if (value.isNull()) return QStringLiteral(""); 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; @@ -82,14 +74,13 @@ struct MessageRow { } // namespace ChatStore::ChatStore(QObject* parent) -: QObject(parent), m_connectionName(QUuid::createUuid().toString()) { + : QObject(parent), m_connectionName(QUuid::createUuid().toString()) { openDb(); load(); } ChatStore::~ChatStore() { - if (m_connectionName.isEmpty()) - return; + if (m_connectionName.isEmpty()) return; QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); db.close(); QSqlDatabase::removeDatabase(m_connectionName); @@ -110,7 +101,7 @@ void ChatStore::openDb() { db.setDatabaseName(m_dbPath); if (!db.open()) { qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" - << db.lastError().text(); + << db.lastError().text(); return; } { @@ -119,59 +110,55 @@ void ChatStore::openDb() { } { QSqlQuery query(db); - query.exec( - QStringLiteral( - "CREATE TABLE IF NOT EXISTS sessions (\n" - " id TEXT PRIMARY KEY,\n" - " title TEXT NOT NULL DEFAULT '',\n" - " icon TEXT NOT NULL DEFAULT '',\n" - " created_at INTEGER NOT NULL,\n" - " updated_at INTEGER NOT NULL,\n" - " pinned INTEGER NOT NULL DEFAULT 0\n" - ")")); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS sessions (\n" + " id TEXT PRIMARY KEY,\n" + " title TEXT NOT NULL DEFAULT '',\n" + " icon TEXT NOT NULL DEFAULT '',\n" + " created_at INTEGER NOT NULL,\n" + " updated_at INTEGER NOT NULL,\n" + " pinned INTEGER NOT NULL DEFAULT 0\n" + ")")); } { 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" - ")")); + 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 generations (\n" - " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" - " message_id INTEGER NOT NULL REFERENCES messages " - "(id) ON DELETE CASCADE,\n" - " timestamp INTEGER NOT NULL,\n" - " is_active INTEGER NOT NULL DEFAULT 1\n" - ")")); + query.exec(QStringLiteral( + "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" + " timestamp INTEGER NOT NULL,\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" - ")")); + 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); @@ -200,8 +187,7 @@ QVariantList ChatStore::values() const { } ChatSession* ChatStore::at(int index) const { - if (index < 0 || index >= m_sessions.size()) - return nullptr; + if (index < 0 || index >= m_sessions.size()) return nullptr; return m_sessions.at(index); } @@ -218,7 +204,7 @@ ChatSession* ChatStore::insert(int index) { query.bindValue(":updated_at", now); if (!query.exec()) qWarning() << "ChatStore: failed to insert session" << id << ":" - << query.lastError().text(); + << query.lastError().text(); } auto* session = new ChatSession(id, this); session->setMeta(QString(), now, now, 0); @@ -238,8 +224,7 @@ void ChatStore::remove(ChatSession* chat) { } void ChatStore::removeSession(ChatSession* session) { - if (!session || !m_sessions.contains(session)) - return; + if (!session || !m_sessions.contains(session)) return; const QList before = m_sessions; Q_EMIT sessionRemoved(session); { @@ -255,7 +240,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(); @@ -269,8 +254,7 @@ void ChatStore::clear() { ChatSession* ChatStore::sessionById(const QString& id) { for (auto* session : m_sessions) - if (session->id() == id) - return session; + if (session->id() == id) return session; return nullptr; } @@ -279,32 +263,28 @@ void ChatStore::setLlmClient(LlmClient* client) { } void ChatStore::persist(ChatSession* session) { - if (!session || !m_sessions.contains(session)) - return; + 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()); - if (!saveSession(session)) - return; + if (!saveSession(session)) return; sortAndNotify(); } void ChatStore::saveMeta(ChatSession* session) { - if (!session || !m_sessions.contains(session)) - return; + if (!session || !m_sessions.contains(session)) return; QSqlQuery query(db()); - query.prepare("UPDATE sessions SET title = :title, icon = :icon " - "WHERE id = :id"); + query.prepare( + "UPDATE sessions SET title = :title, icon = :icon " + "WHERE id = :id"); 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() - << ":" << query.lastError().text(); + << ":" << query.lastError().text(); } bool ChatStore::saveSession(ChatSession* session) { @@ -312,7 +292,7 @@ bool ChatStore::saveSession(ChatSession* session) { QSqlDatabase handle = db(); if (!handle.transaction()) { qWarning() << "ChatStore: failed to begin transaction:" - << handle.lastError().text(); + << handle.lastError().text(); return false; } bool ok = true; @@ -338,18 +318,18 @@ bool ChatStore::saveSession(ChatSession* session) { "INSERT INTO messages (session_id, role, timestamp) " "VALUES (:id, :role, :timestamp)"); QSqlQuery generationInsert(handle); - ok = ok && generationInsert.prepare( - "INSERT INTO generations (message_id, timestamp, is_active) " - "VALUES (:mid, :timestamp, :is_active)"); + ok = ok && + generationInsert.prepare( + "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. + 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)"); const auto* model = session->messagesModel(); for (int row = model->rowCount() - 1; ok && row >= 0; --row) { const auto* message = model->at(row); @@ -363,8 +343,8 @@ bool ChatStore::saveSession(ChatSession* session) { if (!messageInsert.exec()) { ok = false; qWarning() << "ChatStore: saveSession" << id - << "message insert failed:" - << messageInsert.lastError().text(); + << "message insert failed:" + << messageInsert.lastError().text(); break; } const int messageId = messageInsert.lastInsertId().toInt(); @@ -379,8 +359,8 @@ bool ChatStore::saveSession(ChatSession* session) { if (!generationInsert.exec()) { ok = false; qWarning() << "ChatStore: saveSession" << id - << "generation insert failed:" - << generationInsert.lastError().text(); + << "generation insert failed:" + << generationInsert.lastError().text(); break; } const int generationId = @@ -395,18 +375,17 @@ bool ChatStore::saveSession(ChatSession* session) { ":tool_call_id", sqlText(segment->toolCallId())); segmentInsert.bindValue( ":arguments", sqlText(segment->arguments())); - segmentInsert.bindValue(":result", sqlText(segment->result())); + segmentInsert.bindValue( + ":result", sqlText(segment->result())); segmentInsert.bindValue( ":status", static_cast(segment->status())); - segmentInsert.bindValue( - ":elapsed_ms", segment->elapsedMs()); - segmentInsert.bindValue( - ":timestamp", segment->timestamp()); + 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(); + << "segment insert failed:" + << segmentInsert.lastError().text(); break; } } @@ -414,211 +393,183 @@ bool ChatStore::saveSession(ChatSession* session) { } } if (!ok || !handle.commit()) { - qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back"; + qWarning() << "ChatStore: saveSession" << id + << "commit failed, rolling back"; handle.rollback(); ok = false; - qWarning() << "ChatStore: failed to save session" << session->id() << ":" - << handle.lastError().text(); + qWarning() << "ChatStore: failed to save session" << session->id() + << ":" << handle.lastError().text(); } return ok; } void ChatStore::loadMessagesInto(ChatSession* session) { - if (!session) - return; + if (!session) return; 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(this), - session = QPointer(session), sessionId, path]() { - QList 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(); + QThreadPool::globalInstance()->start([store = QPointer(this), + session = + QPointer(session), + sessionId, + path]() { + QList rows; + const QString connName = QUuid::createUuid().toString(); + { + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName); + db.setDatabaseName(path); + if (db.open()) { + QSqlQuery busy(db); + busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); + 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 { - qWarning() - << "ChatStore: failed to open database for load:" - << db.lastError().text(); - } - } - // 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 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( - segmentRow.status)); - segment->restore(segmentRow.elapsedMs); - generation->addSegment(segment); + 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); } - if (generationRow.active) - activeIndex = i; + } else { + qWarning() + << "ChatStore: failed to load generations " + "for message" + << messageId << ":" + << generationQuery.lastError().text(); } - message->setActiveGeneration(activeIndex); - messages.append(message); + rows.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 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); + } + db.close(); + } else { + qWarning() << "ChatStore: failed to open database for load:" + << db.lastError().text(); + } + } + QSqlDatabase::removeDatabase(connName); - // 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(); + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [store, session, rows = std::move(rows)]() mutable { + ChatStore* st = store; + ChatSession* s = session; + if (!st || !s) return; - 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); + auto* model = s->model(); + if (!model) return; + QList 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( + segmentRow.status)); + segment->restore(segmentRow.elapsedMs); + generation->addSegment(segment); + } + if (generationRow.active) activeIndex = i; } - }, - Qt::QueuedConnection); - }); + message->setActiveGeneration(activeIndex); + messages.append(message); + } + if (model->rowCount() > 0) { + QList 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); + + s->markLoaded(); + + if (s->takeClearPending()) { + s->clear(); + } else if (st->m_pendingPersists.remove(s)) { + st->persist(s); + } + }, + Qt::QueuedConnection); + }); } void ChatStore::load() { @@ -647,22 +598,18 @@ void ChatStore::sortAndNotify() { m_sessions.begin(), m_sessions.end(), [](const ChatSession* a, const ChatSession* b) { - if (a->pinned() != b->pinned()) - return a->pinned() > b->pinned(); + if (a->pinned() != b->pinned()) return a->pinned() > b->pinned(); return a->updatedAtMs() > b->updatedAtMs(); }); notify(before); } void ChatStore::notify(const QList& before) { - if (before.size() != m_sessions.size()) - Q_EMIT countChanged(); + if (before.size() != m_sessions.size()) Q_EMIT countChanged(); bool same = before.size() == m_sessions.size(); for (int i = 0; same && i < m_sessions.size(); ++i) - if (before.at(i) != m_sessions.at(i)) - same = false; - if (!same) - Q_EMIT valuesChanged(); + if (before.at(i) != m_sessions.at(i)) same = false; + if (!same) Q_EMIT valuesChanged(); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp index f8fe539..75b335b 100644 --- a/Plugins/ZShell/Llm/chatstore.hpp +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -39,9 +39,6 @@ class ChatStore : public QObject { void persist(ChatSession* session); void saveMeta(ChatSession* session); - // Loads the session's messages from the database. The SQL runs on a - // worker thread; the object tree is built and the model updated on - // the GUI thread when it arrives (ChatSession::loaded). void loadMessagesInto(ChatSession* session); Q_SIGNALS: @@ -61,8 +58,6 @@ class ChatStore : public QObject { LlmClient* m_llmClient = nullptr; QString m_connectionName; QString m_dbPath; - // Sessions whose persist() ran before their messages finished - // loading; persisted once the load lands. QSet m_pendingPersists; [[nodiscard]] QSqlDatabase db() const; diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp index c926c57..86e2f54 100644 --- a/Plugins/ZShell/Llm/codehighlighter.cpp +++ b/Plugins/ZShell/Llm/codehighlighter.cpp @@ -213,7 +213,6 @@ QVariantList CodeHighlighter::lookupSpans( QMutexLocker locker(&m_cacheMutex); const auto it = m_spanCache.constFind(key); if (it == m_spanCache.constEnd() || it->code != code) return {}; - // Most recently used; eviction drops the oldest entries first. const qsizetype pos = m_spanCacheOrder.indexOf(key); if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1); return it->spans; diff --git a/Plugins/ZShell/Llm/codehighlighter.hpp b/Plugins/ZShell/Llm/codehighlighter.hpp index 3987379..4f0b116 100644 --- a/Plugins/ZShell/Llm/codehighlighter.hpp +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -15,38 +15,6 @@ class QJSEngine; namespace ZShell::llm { -// Syntax highlighting for LLM code blocks via tree-sitter. -// -// The tree-sitter runtime is linked. Grammar libraries are dlopen()'d -// lazily, so a missing grammar degrades that language to plain text -// instead of breaking the build or the app. At configure time CMake -// discovers installed grammars (system packages plus the parsers -// Neovim's nvim-treesitter installs) and pairs each with highlight -// queries (vendored, Neovim's, or fetched from -// tree-sitter/highlighting); both are embedded in the generated -// highlight-queries.hpp. -// -// For each grammar the first loadable (ABI-compatible) library wins -// and the first query that compiles against it wins, so a version -// skew between a library and its query degrades gracefully. -// -// The fence language the LLM wrote (```cpp, ```python, ...) is mapped -// to a grammar through an alias table; tags not in the table are used -// as grammar ids as-is. -// -// highlight() parses the code off the GUI thread and delivers a list of -// span maps by calling target's "onHighlightSpans(token, spans)" method -// (on the GUI thread): -// { "start": int, "length": int, "kind": QString } -// where kind is a semantic role (keyword, string, comment, number, -// function, type, ...) that QML maps to theme colors. An empty list -// means "no highlighting" (unknown language or grammar not installed). -// token is passed back unchanged so the caller can drop results for -// superseded code; a destroyed target is simply skipped. -// -// Successful results are cached by (language, code). A request for -// unchanged code delivers the cached spans directly, without re-parsing -// — while a segment streams, only the grown tail is ever re-parsed. class CodeHighlighter : public QObject { Q_OBJECT QML_ELEMENT @@ -54,17 +22,16 @@ class CodeHighlighter : public QObject { public: Q_INVOKABLE void highlight( - const QString& code, const QString& language, QObject* target, int token); + const QString& code, + const QString& language, + QObject* target, + int token); static CodeHighlighter* create(QQmlEngine*, QJSEngine*); struct Grammar { - // Library candidates in priority order (system package, then - // Neovim copies); libs[i] pairs with symbols[i]. std::vector libs; std::vector symbols; - // Candidate query sources in priority order; first that - // compiles against the loaded grammar wins. std::vector queries; }; @@ -73,29 +40,27 @@ class CodeHighlighter : public QObject { bool bad = false; // permanent failure, do not retry void* lib = nullptr; const void* lang = nullptr; // const TSLanguage* - void* query = nullptr; // TSQuery* + void* query = nullptr; // TSQuery* }; [[nodiscard]] static const QHash& aliases(); - // Maps a tree-sitter capture name to a role index (0 = unstyled). [[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length); [[nodiscard]] static const char* roleName(uint8_t role); - // Maps a fence language tag to the grammar id (see aliases()). [[nodiscard]] static QString resolveId(const QString& language); - [[nodiscard]] static QString cacheKey(const QString& id, const QString& code); - // The parsing work; runs on worker threads, so the per-language - // state must be initialized under m_stateMutex and is shared as an - // immutable object afterwards. - [[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const; - // Exact-match span cache. lookupSpans() runs on the GUI thread, - // storeSpans() on worker threads; both take m_cacheMutex. - [[nodiscard]] QVariantList lookupSpans(const QString& code, const QString& language) const; - void storeSpans(const QString& code, const QString& language, - const QVariantList& spans) const; + [[nodiscard]] static QString cacheKey( + const QString& id, const QString& code); + [[nodiscard]] QVariantList doHighlight( + const QString& code, const QString& language) const; + [[nodiscard]] QVariantList lookupSpans( + const QString& code, const QString& language) const; + void storeSpans( + const QString& code, + const QString& language, + const QVariantList& spans) const; struct SpanCacheEntry { QString code; // re-compared on lookup; a hash collision can - // never deliver the wrong spans + // never deliver the wrong spans QVariantList spans; }; diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index bd96d4f..129de52 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -5,22 +5,19 @@ namespace ZShell::llm { ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) -: QObject(parent), m_timestamp(timestamp) { + : QObject(parent), m_timestamp(timestamp) { m_timer.setParent(this); m_timer.setInterval(500); m_timer.setTimerType(Qt::CoarseTimer); connect(&m_timer, &QTimer::timeout, this, [this]() { bool anyRunning = false; for (auto* segment : m_segments) { - if (!segment->running()) - continue; + if (!segment->running()) continue; anyRunning = true; segment->elapsedMsChanged(); } - if (anyRunning) - Q_EMIT elapsedMsChanged(); - if (!anyRunning && !m_streaming) - m_timer.stop(); + if (anyRunning) Q_EMIT elapsedMsChanged(); + if (!anyRunning && !m_streaming) m_timer.stop(); }); } @@ -28,7 +25,7 @@ QString ChatGeneration::content() const { QStringList parts; for (const auto* segment : m_segments) { if (segment->type() != LlmSegment::Type::Content || - segment->text().isEmpty()) + segment->text().isEmpty()) continue; parts.append(segment->text()); } @@ -39,7 +36,7 @@ QString ChatGeneration::reasoning() const { QStringList parts; for (const auto* segment : m_segments) { if (segment->type() != LlmSegment::Type::Reasoning || - segment->text().isEmpty()) + segment->text().isEmpty()) continue; parts.append(segment->text()); } @@ -47,10 +44,8 @@ QString ChatGeneration::reasoning() const { } bool ChatGeneration::reasoningActive() const { - if (!m_streaming) - return false; - if (!content().isEmpty()) - return false; + if (!m_streaming) return false; + if (!content().isEmpty()) return false; return !hasRunningTool(); } @@ -81,44 +76,35 @@ qint64 ChatGeneration::toolsElapsedMs() const { int ChatGeneration::toolCallCount() const { int count = 0; for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall) - ++count; + if (segment->type() == LlmSegment::Type::ToolCall) ++count; return count; } bool ChatGeneration::hasRunningTool() const { for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall && - segment->running()) + if (segment->type() == LlmSegment::Type::ToolCall && segment->running()) return true; return false; } void ChatGeneration::updateReasoningActive() { const bool active = reasoningActive(); - if (m_reasoningActive == active) - return; + if (m_reasoningActive == active) return; m_reasoningActive = active; Q_EMIT reasoningActiveChanged(); } void ChatGeneration::setContent(const QString& value) { - // Replaces the entire answer: the first content segment takes the - // new text, any later content bursts are cleared. LlmSegment* first = nullptr; for (auto* segment : m_segments) { - if (segment->type() != LlmSegment::Type::Content) - continue; + if (segment->type() != LlmSegment::Type::Content) continue; if (!first) first = segment; else segment->setText(QString()); } if (!first) { - // Do not materialize an empty content segment (e.g. the assistant - // placeholder created before the stream starts). - if (value.isEmpty()) - return; + if (value.isEmpty()) return; first = new LlmSegment( LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), @@ -129,35 +115,30 @@ void ChatGeneration::setContent(const QString& value) { } void ChatGeneration::appendContent(const QString& piece) { - if (piece.isEmpty()) - return; + if (piece.isEmpty()) return; for (auto* segment : m_segments) { if (segment->type() == LlmSegment::Type::Reasoning && - segment->running()) + segment->running()) segment->close(); } openContentSegment()->appendText(piece); } void ChatGeneration::appendReasoning(const QString& piece) { - if (piece.isEmpty()) - return; + if (piece.isEmpty()) return; for (auto* segment : m_segments) { - if (segment->type() == LlmSegment::Type::Content && - segment->running()) + if (segment->type() == LlmSegment::Type::Content && segment->running()) segment->close(); } openReasoningSegment()->appendText(piece); } void ChatGeneration::setStreaming(bool value) { - if (m_streaming == value) - return; + if (m_streaming == value) return; m_streaming = value; Q_EMIT streamingChanged(); if (value) { - if (!m_timer.isActive()) - m_timer.start(); + if (!m_timer.isActive()) m_timer.start(); } else { closeOpenSegments(); m_timer.stop(); @@ -168,13 +149,10 @@ void ChatGeneration::setStreaming(bool value) { LlmSegment* ChatGeneration::openContentSegment() { for (auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::Content && - segment->running()) + if (segment->type() == LlmSegment::Type::Content && segment->running()) return segment; auto* segment = new LlmSegment( - LlmSegment::Type::Content, - QDateTime::currentMSecsSinceEpoch(), - this); + LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this); segment->begin(); addSegment(segment); return segment; @@ -183,12 +161,10 @@ LlmSegment* ChatGeneration::openContentSegment() { LlmSegment* ChatGeneration::openReasoningSegment() { for (auto* segment : m_segments) if (segment->type() == LlmSegment::Type::Reasoning && - segment->running()) + segment->running()) return segment; auto* segment = new LlmSegment( - LlmSegment::Type::Reasoning, - QDateTime::currentMSecsSinceEpoch(), - this); + LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this); segment->begin(); addSegment(segment); return segment; @@ -198,9 +174,7 @@ LlmSegment* ChatGeneration::beginToolCall( const QString& name, const QString& toolCallId) { closeOpenSegments(); auto* segment = new LlmSegment( - LlmSegment::Type::ToolCall, - QDateTime::currentMSecsSinceEpoch(), - this); + LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); segment->setName(name); segment->setToolCallId(toolCallId); segment->setStatus(LlmSegment::Status::Running); @@ -211,39 +185,33 @@ LlmSegment* ChatGeneration::beginToolCall( } void ChatGeneration::addSegment(LlmSegment* segment) { - if (!segment || m_segments.contains(segment)) - return; + if (!segment || m_segments.contains(segment)) return; segment->setParent(this); - connect( - segment, &LlmSegment::textChanged, this, [this, segment]() { - if (segment->type() == LlmSegment::Type::Reasoning) - Q_EMIT reasoningChanged(); - else if (segment->type() == LlmSegment::Type::Content) - Q_EMIT contentChanged(); - updateReasoningActive(); - }); - connect( - segment, &LlmSegment::statusChanged, this, [this]() { - Q_EMIT toolStateChanged(); - }); - connect( - segment, &LlmSegment::resultChanged, this, [this]() { - Q_EMIT toolStateChanged(); - }); - connect( - segment, &LlmSegment::runningChanged, this, [this]() { - Q_EMIT elapsedMsChanged(); - Q_EMIT toolStateChanged(); - updateReasoningActive(); - }); + connect(segment, &LlmSegment::textChanged, this, [this, segment]() { + if (segment->type() == LlmSegment::Type::Reasoning) + Q_EMIT reasoningChanged(); + else if (segment->type() == LlmSegment::Type::Content) + Q_EMIT contentChanged(); + updateReasoningActive(); + }); + connect(segment, &LlmSegment::statusChanged, this, [this]() { + Q_EMIT toolStateChanged(); + }); + connect(segment, &LlmSegment::resultChanged, this, [this]() { + Q_EMIT toolStateChanged(); + }); + connect(segment, &LlmSegment::runningChanged, this, [this]() { + Q_EMIT elapsedMsChanged(); + Q_EMIT toolStateChanged(); + updateReasoningActive(); + }); m_segments.append(segment); Q_EMIT segmentsChanged(); } void ChatGeneration::closeOpenSegments() { for (auto* segment : m_segments) - if (segment->running()) - segment->close(); + if (segment->running()) segment->close(); updateReasoningActive(); } diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp index c6e093e..20b4709 100644 --- a/Plugins/ZShell/Llm/generation.hpp +++ b/Plugins/ZShell/Llm/generation.hpp @@ -10,39 +10,35 @@ namespace ZShell::llm { -// One attempt at answering a message: a chronologically ordered list -// of segments. Content bursts, reasoning bursts and tool calls all -// appear in the order the model produced them; a new content (or -// reasoning) segment starts whenever the model switches between them. -// Together with the attempt's aggregate state. class ChatGeneration : public QObject { Q_OBJECT QML_ELEMENT QML_UNCREATABLE("Chat generations are managed by ChatMessage") - Q_PROPERTY(QString content READ content WRITE setContent NOTIFY contentChanged) + Q_PROPERTY( + QString content READ content WRITE setContent NOTIFY contentChanged) Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) - Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) - Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY( + qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY + elapsedMsChanged) + Q_PROPERTY( + qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) - Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) + Q_PROPERTY( + bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY( - QList segments READ segments - NOTIFY segmentsChanged) + QList segments READ segments NOTIFY + segmentsChanged) Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) public: explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); - // All content bursts, joined (the model's full answer). [[nodiscard]] QString content() const; - // Every reasoning burst, joined. [[nodiscard]] QString reasoning() const; - // True while the model is thinking: streaming, no content yet, and - // no tool call in flight. [[nodiscard]] bool reasoningActive() const; [[nodiscard]] qint64 reasoningElapsedMs() const; [[nodiscard]] qint64 contentElapsedMs() const; @@ -58,18 +54,11 @@ class ChatGeneration : public QObject { void appendReasoning(const QString& piece); void setStreaming(bool value); - // The in-flight content segment, or a fresh one. A new content - // segment starts whenever the model resumes writing after reasoning - // or a tool call. [[nodiscard]] LlmSegment* openContentSegment(); - // The in-flight reasoning segment, or a fresh one. [[nodiscard]] LlmSegment* openReasoningSegment(); - // Creates and appends a running tool-call segment. [[nodiscard]] LlmSegment* beginToolCall( const QString& name, const QString& toolCallId); - // Appends a segment created by the persistence layer. void addSegment(LlmSegment* segment); - // Stops the clocks of every in-flight segment. void closeOpenSegments(); Q_SIGNALS: diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index b898456..b29f92b 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -22,8 +22,7 @@ QString LlmClient::completionsPath( QString base = endpoint.trimmed(); while (base.endsWith('/')) base.chop(1); - if (!base.endsWith("/v1")) - base += "/v1"; + if (!base.endsWith("/v1")) base += "/v1"; return base + subpath; } @@ -37,28 +36,24 @@ QString LlmClient::serverErrorMessage( if (errorValue.isObject()) { const QString message = errorValue.toObject()["message"].toString(); - if (!message.isEmpty()) - return message; + if (!message.isEmpty()) return message; } else if (!errorValue.toString().isEmpty()) { return errorValue.toString(); } } } - return fallback.isEmpty() - ? QStringLiteral("Request to LLM server failed") - : fallback; + return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") + : fallback; } void LlmClient::setBusy(bool value) { - if (m_busy == value) - return; + if (m_busy == value) return; m_busy = value; Q_EMIT busyChanged(); } void LlmClient::setStreamingChatId(const QString& id) { - if (m_streamingChatId == id) - return; + if (m_streamingChatId == id) return; m_streamingChatId = id; Q_EMIT streamingChatIdChanged(); } @@ -74,39 +69,32 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) { } LlmClient::~LlmClient() { - if (m_reply) - m_reply->abort(); + if (m_reply) m_reply->abort(); m_tools->cancelAll(); endStream(); } void LlmClient::setEndpoint(const QString& value) { - if (m_endpoint == value) - return; + if (m_endpoint == value) return; m_endpoint = value; Q_EMIT endpointChanged(); probeContextSize(); - if (m_model.isEmpty()) - refreshModels(); + if (m_model.isEmpty()) refreshModels(); } void LlmClient::setModel(const QString& value) { - if (m_model == value) - return; + if (m_model == value) return; m_model = value; Q_EMIT modelChanged(); - if (m_model.isEmpty()) - refreshModels(); + if (m_model.isEmpty()) refreshModels(); } void LlmClient::setTemperature(double value) { m_temperature = value; } -void LlmClient::startGeneration( - ChatSession* session, ChatGeneration* target) { - if (m_busy || !session || !target) - return; +void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { + if (m_busy || !session || !target) return; m_active = session; m_streaming = target; m_streaming->setStreaming(true); @@ -124,8 +112,9 @@ void LlmClient::startGeneration( const int targetRow = model->rowOf(qobject_cast(target->parent())); if (targetRow < 0) { - fail(QStringLiteral("Internal error: generation target is not in the " - "session")); + fail(QStringLiteral( + "Internal error: generation target is not in the " + "session")); return; } @@ -141,8 +130,7 @@ void LlmClient::startGeneration( } void LlmClient::sendRound() { - if (!m_active || !m_streaming) - return; + if (!m_active || !m_streaming) return; m_finishReason.clear(); m_callBuilders.clear(); m_callResults.clear(); @@ -162,13 +150,12 @@ void LlmClient::sendRound() { const int targetRow = model->rowOf(qobject_cast(m_streaming->parent())); if (targetRow < 0) { - fail(QStringLiteral("Internal error: generation target is not in the " - "session")); + fail(QStringLiteral( + "Internal error: generation target is not in the " + "session")); return; } - // Context: every message older than the target, oldest first, plus - // the tool exchanges of the current turn so far. QJsonArray messages = buildContextMessages(m_active, targetRow); for (const QJsonValue& value : m_transcript) messages.append(value); @@ -180,11 +167,9 @@ void LlmClient::sendRound() { body[QStringLiteral("messages")] = messages; body[QStringLiteral("stream")] = true; body[QStringLiteral("temperature")] = m_temperature; - if (!m_model.isEmpty()) - body[QStringLiteral("model")] = m_model; + if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model; const QJsonArray toolSpecs = m_tools->specifications(); - if (!toolSpecs.isEmpty()) - body[QStringLiteral("tools")] = toolSpecs; + if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs; QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); @@ -193,26 +178,22 @@ void LlmClient::sendRound() { m_reply = m_manager.post(request, QJsonDocument(body).toJson()); connect(m_reply, &QNetworkReply::readyRead, this, [this]() { - if (m_reply) - m_buffer.append(m_reply->readAll()); + if (m_reply) m_buffer.append(m_reply->readAll()); drainBuffer(); }); connect(m_reply, &QNetworkReply::finished, this, [this]() { QNetworkReply* reply = m_reply; - if (!reply) - return; + if (!reply) return; m_reply = nullptr; const QNetworkReply::NetworkError error = reply->error(); const QString errorString = reply->errorString(); - // Data not already consumed by readyRead is only reachable here. const QByteArray responseBody = reply->readAll(); m_buffer.append(responseBody); reply->deleteLater(); drainBuffer(); - if (!m_streaming) - return; + if (!m_streaming) return; if (error == QNetworkReply::NoError) roundFinished(); @@ -230,8 +211,7 @@ QJsonArray LlmClient::buildContextMessages( for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { const auto* message = model->at(row); const auto* generation = message->activeGeneration(); - if (!generation) - continue; + if (!generation) continue; if (message->role() == ChatMessage::Role::User) { QJsonObject user; @@ -241,8 +221,6 @@ QJsonArray LlmClient::buildContextMessages( continue; } - // Assistant message: replay its tool calls (and their results) - // so the model keeps the full history of the turn. QList toolSegments; for (const auto* segment : generation->segments()) if (segment->type() == LlmSegment::Type::ToolCall) @@ -278,8 +256,7 @@ QJsonArray LlmClient::buildContextMessages( for (const auto* segment : toolSegments) { QJsonObject toolMessage; toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); - toolMessage[QStringLiteral("tool_call_id")] = - segment->toolCallId(); + toolMessage[QStringLiteral("tool_call_id")] = segment->toolCallId(); toolMessage[QStringLiteral("content")] = segment->result(); messages.append(toolMessage); } @@ -288,51 +265,37 @@ QJsonArray LlmClient::buildContextMessages( } void LlmClient::applyToolCallDelta(const QJsonObject& call) { - if (!m_streaming) - return; + if (!m_streaming) return; const int index = call[QStringLiteral("index")].toInt(-1); - if (index < 0) - return; + if (index < 0) return; while (m_callBuilders.size() <= index) m_callBuilders.append(ToolCallBuilder{}); ToolCallBuilder& builder = m_callBuilders[index]; builder.seen = true; const QString id = call[QStringLiteral("id")].toString(); - if (!id.isEmpty()) - builder.id = id; + if (!id.isEmpty()) builder.id = id; const QJsonObject function = call[QStringLiteral("function")].toObject(); const QString name = function[QStringLiteral("name")].toString(); - if (!name.isEmpty()) - builder.name = name; - const QString arguments = - function[QStringLiteral("arguments")].toString(); - if (!arguments.isEmpty()) - builder.arguments += arguments; + if (!name.isEmpty()) builder.name = name; + const QString arguments = function[QStringLiteral("arguments")].toString(); + if (!arguments.isEmpty()) builder.arguments += arguments; if (!builder.segment) { - // A new call: close the in-flight text segments and open a - // running tool-call segment so the UI can track it live. m_streaming->closeOpenSegments(); - builder.segment = - m_streaming->beginToolCall(builder.name, builder.id); + builder.segment = m_streaming->beginToolCall(builder.name, builder.id); } builder.segment->setName(builder.name); builder.segment->setToolCallId(builder.id); - if (!arguments.isEmpty()) - builder.segment->appendArguments(arguments); + if (!arguments.isEmpty()) builder.segment->appendArguments(arguments); } void LlmClient::roundFinished() { - // [DONE] and the reply's finished signal both funnel here; only the - // first may act. - if (m_roundDone || !m_streaming) - return; + if (m_roundDone || !m_streaming) return; m_roundDone = true; bool hasCalls = false; for (const auto& builder : m_callBuilders) - if (builder.seen) - hasCalls = true; + if (builder.seen) hasCalls = true; if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) { finishTurn(); return; @@ -345,8 +308,6 @@ void LlmClient::roundFinished() { m_streaming->closeOpenSegments(); - // Record the assistant's tool-call message in the transcript so the - // next round (and the model) can see it. const QString content = m_streaming->content(); const QString reasoning = m_streaming->reasoning(); QJsonObject assistant; @@ -362,8 +323,7 @@ void LlmClient::roundFinished() { } QJsonArray calls; for (const auto& builder : m_callBuilders) { - if (!builder.seen) - continue; + if (!builder.seen) continue; QJsonObject function; function[QStringLiteral("name")] = builder.name; function[QStringLiteral("arguments")] = builder.arguments; @@ -387,112 +347,95 @@ void LlmClient::executeAllCalls() { for (int i = 0; i < m_callBuilders.size(); ++i) { const auto& call = m_callBuilders.at(i); - if (!call.seen) - continue; + if (!call.seen) continue; LlmTool* tool = m_tools->tool(call.name); QJsonObject args; QString errorText; if (!tool) { - errorText = QStringLiteral("Error: unknown tool '%1'") - .arg(call.name); + errorText = + QStringLiteral("Error: unknown tool '%1'").arg(call.name); } else if (!call.arguments.isEmpty()) { const QJsonDocument doc = QJsonDocument::fromJson(call.arguments.toUtf8()); if (!doc.isObject()) { - errorText = - QStringLiteral("Error: tool arguments are not valid " - "JSON: %1") - .arg(call.arguments); + errorText = QStringLiteral( + "Error: tool arguments are not valid " + "JSON: %1") + .arg(call.arguments); } else { args = doc.object(); } } if (!errorText.isEmpty()) { - m_callResults[i] = { errorText, false }; + m_callResults[i] = {errorText, false}; if (LlmSegment* segment = call.segment) segment->finishTool(errorText, false); continue; } ++m_pendingCalls; - tool->execute( - args, - [this, i, call](const QJsonObject& result) { - if (!m_streaming) - return; - const bool success = - result.contains(QStringLiteral("output")); - const QString content = success - ? result[QStringLiteral("output")].toString() - : QStringLiteral("Error: ") + - result[QStringLiteral("error")].toString(); - m_callResults[i] = { content, success }; - if (LlmSegment* segment = call.segment) - segment->finishTool(content, success); - if (--m_pendingCalls == 0) - flushCallResults(); - }); + tool->execute(args, [this, i, call](const QJsonObject& result) { + if (!m_streaming) return; + const bool success = result.contains(QStringLiteral("output")); + const QString content = + success ? result[QStringLiteral("output")].toString() + : QStringLiteral("Error: ") + + result[QStringLiteral("error")].toString(); + m_callResults[i] = {content, success}; + if (LlmSegment* segment = call.segment) + segment->finishTool(content, success); + if (--m_pendingCalls == 0) flushCallResults(); + }); } - if (m_pendingCalls == 0) - flushCallResults(); + if (m_pendingCalls == 0) flushCallResults(); } void LlmClient::flushCallResults() { - if (!m_toolPhase) - return; + if (!m_toolPhase) return; m_toolPhase = false; - if (!m_streaming) - return; + if (!m_streaming) return; for (int i = 0; i < m_callBuilders.size(); ++i) { const auto& call = m_callBuilders.at(i); - if (!call.seen) - continue; + if (!call.seen) continue; QJsonObject toolMessage; toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); toolMessage[QStringLiteral("tool_call_id")] = call.id; - toolMessage[QStringLiteral("content")] = - m_callResults.at(i).content; + toolMessage[QStringLiteral("content")] = m_callResults.at(i).content; m_transcript.append(toolMessage); } sendRound(); } void LlmClient::stop() { - if (!m_busy) - return; + if (!m_busy) return; if (m_toolPhase) { m_tools->cancelAll(); if (m_streaming) { for (auto* segment : m_streaming->segments()) { if (segment->type() == LlmSegment::Type::ToolCall && - segment->running()) - segment->finishTool( - QStringLiteral("Cancelled"), false); + segment->running()) + segment->finishTool(QStringLiteral("Cancelled"), false); } } finishTurn(); return; } - if (m_reply) - m_reply->abort(); + if (m_reply) m_reply->abort(); } void LlmClient::endStream() { - if (!m_streaming) - return; + if (!m_streaming) return; auto* generation = m_streaming; auto* session = m_active; m_streaming = nullptr; m_active = nullptr; generation->setStreaming(false); - if (generation->content().isEmpty() && - generation->reasoning().isEmpty() && - generation->toolCallCount() == 0) { + if (generation->content().isEmpty() && generation->reasoning().isEmpty() && + generation->toolCallCount() == 0) { if (auto* message = qobject_cast(generation->parent())) { if (message->generationCount() <= 1) { - if (session) - session->removeMessage(message); + if (session) session->removeMessage(message); } else { message->removeGeneration(generation); } @@ -503,16 +446,14 @@ void LlmClient::endStream() { } void LlmClient::finishTurn() { - if (!m_streaming) - return; + if (!m_streaming) return; ChatSession* session = m_active; endStream(); if (session && m_pendingClear == session) { session->clearMessages(); m_pendingClear.clear(); } - if (session) - session->persist(); + if (session) session->persist(); } void LlmClient::clearOnFinish(ChatSession* session) { @@ -520,8 +461,7 @@ void LlmClient::clearOnFinish(ChatSession* session) { } void LlmClient::sessionRemoved(ChatSession* session) { - if (m_pendingClear == session) - m_pendingClear.clear(); + if (m_pendingClear == session) m_pendingClear.clear(); if (m_active == session) { stop(); endStream(); @@ -537,8 +477,7 @@ void LlmClient::fail(const QString& message) { void LlmClient::drainBuffer() { while (true) { const qsizetype newline = m_buffer.indexOf('\n'); - if (newline < 0) - break; + if (newline < 0) break; const QByteArray line = m_buffer.left(newline).trimmed(); m_buffer.remove(0, newline + 1); handleLine(line); @@ -546,8 +485,7 @@ void LlmClient::drainBuffer() { } void LlmClient::handleLine(const QByteArray& line) { - if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) - return; + if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) return; const QByteArray data = line.mid(5).trimmed(); if (data == "[DONE]") { @@ -556,42 +494,35 @@ void LlmClient::handleLine(const QByteArray& line) { } const QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isObject()) - return; + if (!doc.isObject()) return; const QJsonObject obj = doc.object(); updateTokenUsage(obj); if (obj.contains("error")) { const QJsonObject error = obj["error"].toObject(); const QString message = error["message"].toString(); - fail(message.isEmpty() - ? QStringLiteral("LLM server returned an error") - : message); + fail( + message.isEmpty() ? QStringLiteral("LLM server returned an error") + : message); return; } for (const QJsonValue& choiceValue : obj["choices"].toArray()) { - if (!m_streaming) - continue; + if (!m_streaming) continue; const QJsonObject choice = choiceValue.toObject(); const QJsonObject delta = choice["delta"].toObject(); const QString finishReason = choice[QStringLiteral("finish_reason")].toString(); - if (!finishReason.isEmpty()) - m_finishReason = finishReason; + if (!finishReason.isEmpty()) m_finishReason = finishReason; m_streaming->appendContent(delta["content"].toString()); - QString reasoning = - delta["reasoning_content"].toString(); - if (reasoning.isEmpty()) - reasoning = delta["reasoning"].toString(); + QString reasoning = delta["reasoning_content"].toString(); + if (reasoning.isEmpty()) reasoning = delta["reasoning"].toString(); m_streaming->appendReasoning(reasoning); - for (const QJsonValue& callValue : - delta["tool_calls"].toArray()) { - if (!m_streaming) - break; + for (const QJsonValue& callValue : delta["tool_calls"].toArray()) { + if (!m_streaming) break; applyToolCallDelta(callValue.toObject()); } } @@ -600,8 +531,7 @@ void LlmClient::handleLine(const QByteArray& line) { void LlmClient::refreshModels() { const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); - if (!url.isValid() || url.host().isEmpty()) - return; + if (!url.isValid() || url.host().isEmpty()) return; auto* reply = m_manager.get(QNetworkRequest(url)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { @@ -625,8 +555,7 @@ void LlmClient::refreshModels() { models.append(id); } } - if (models.isEmpty()) - return; + if (models.isEmpty()) return; m_availableModels = models; Q_EMIT availableModelsChanged(); @@ -639,66 +568,55 @@ void LlmClient::refreshModels() { } void LlmClient::setContextSize(int size) { - if (size <= 0 || m_contextSize == size) - return; + if (size <= 0 || m_contextSize == size) return; m_contextSize = size; Q_EMIT contextSizeChanged(); } void LlmClient::probeContextSize() { - // llama.cpp-specific endpoint; other servers fall back to 4096. QString base = m_endpoint.trimmed(); while (base.endsWith('/')) base.chop(1); const QUrl url = QUrl::fromUserInput(base + "/props"); - if (!url.isValid() || url.host().isEmpty()) - return; + if (!url.isValid() || url.host().isEmpty()) return; auto* reply = m_manager.get(QNetworkRequest(url)); - connect( - reply, - &QNetworkReply::finished, - this, - [this, reply]() { - const QNetworkReply::NetworkError error = reply->error(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); - int size = 0; - if (error == QNetworkReply::NoError) { - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (doc.isArray()) { - for (const auto& value : doc.array()) { - const QJsonObject slot = value.toObject(); - if (slot.contains("n_ctx")) { - size = slot["n_ctx"].toInt(0); - if (size > 0) - break; - } + int size = 0; + if (error == QNetworkReply::NoError) { + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isArray()) { + for (const auto& value : doc.array()) { + const QJsonObject slot = value.toObject(); + if (slot.contains("n_ctx")) { + size = slot["n_ctx"].toInt(0); + if (size > 0) break; } - } else if (doc.isObject()) { - const QJsonObject obj = doc.object(); - size = obj["n_ctx"].toInt(0); - if (size <= 0) - size = obj["default_generation_settings"].toObject() - ["n_ctx"].toInt(0); } + } else if (doc.isObject()) { + const QJsonObject obj = doc.object(); + size = obj["n_ctx"].toInt(0); + if (size <= 0) + size = obj["default_generation_settings"] + .toObject()["n_ctx"] + .toInt(0); } - setContextSize(size > 0 ? size : 4096); - }); + } + setContextSize(size > 0 ? size : 4096); + }); } void LlmClient::updateTokenUsage(const QJsonObject& data) { - if (!m_active || m_contextSize <= 0) - return; + if (!m_active || m_contextSize <= 0) return; const QJsonObject usage = data["usage"].toObject(); - if (usage.isEmpty()) - return; - const double used = - usage.value("prompt_tokens").toDouble() + - usage.value("completion_tokens").toDouble(); - if (used > 0) - m_active->setLastTokenCount(static_cast(used)); + if (usage.isEmpty()) return; + const double used = usage.value("prompt_tokens").toDouble() + + usage.value("completion_tokens").toDouble(); + if (used > 0) m_active->setLastTokenCount(static_cast(used)); } void LlmClient::shortRequest( @@ -708,11 +626,10 @@ void LlmClient::shortRequest( std::function onResult) { const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); - if (!url.isValid() || url.host().isEmpty()) - return; + if (!url.isValid() || url.host().isEmpty()) return; qInfo() << "LlmClient:" << tag << "request POST" << url.toString() - << "model=" << m_model; + << "model=" << m_model; QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); @@ -729,8 +646,7 @@ void LlmClient::shortRequest( messages.append(user); QJsonObject body; - if (!m_model.isEmpty()) - body[QStringLiteral("model")] = m_model; + if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model; body[QStringLiteral("stream")] = false; body[QStringLiteral("temperature")] = 0.3; body[QStringLiteral("max_tokens")] = 128; @@ -749,25 +665,25 @@ void LlmClient::shortRequest( reply->deleteLater(); qInfo() << "LlmClient:" << tag << "request finished" - << "error=" << reply->error() << reply->errorString() - << "http=" << reply->attribute( - QNetworkRequest::HttpStatusCodeAttribute) - .toInt() - << "response=" - << QString::fromUtf8(data.left(400)).simplified(); + << "error=" << reply->error() << reply->errorString() + << "http=" + << reply + ->attribute(QNetworkRequest::HttpStatusCodeAttribute) + .toInt() + << "response=" + << QString::fromUtf8(data.left(400)).simplified(); - if (reply->error() != QNetworkReply::NoError) - return; + if (reply->error() != QNetworkReply::NoError) return; const QJsonDocument doc = QJsonDocument::fromJson(data); const QJsonArray choices = doc.object()[QStringLiteral("choices")].toArray(); - if (choices.isEmpty()) - return; - const QString result = - choices.at(0).toObject()[QStringLiteral("message")].toObject() - [QStringLiteral("content")].toString() - .trimmed(); + if (choices.isEmpty()) return; + const QString result = choices.at(0) + .toObject()[QStringLiteral("message")] + .toObject()[QStringLiteral("content")] + .toString() + .trimmed(); qInfo() << "LlmClient:" << tag << "raw result" << result; onResult(result); }); @@ -788,24 +704,21 @@ void LlmClient::requestTitle(ChatSession* session, const QString& userText) { } const auto isQuote = [](QChar c) { return c == QLatin1Char('"') || c == QLatin1Char('\'') || - c == QChar(u'\u201C') || c == QChar(u'\u201D') || - c == QChar(u'\u2018') || c == QChar(u'\u2019'); + c == QChar(u'\u201C') || c == QChar(u'\u201D') || + c == QChar(u'\u2018') || c == QChar(u'\u2019'); }; while (title.size() >= 2 && isQuote(title.at(0)) && - isQuote(title.at(title.size() - 1))) + isQuote(title.at(title.size() - 1))) title = title.mid(1, title.size() - 2).simplified(); - while (!title.isEmpty() && - (title.endsWith(QLatin1Char('.')) || - title.endsWith(QLatin1Char('!')) || - title.endsWith(QLatin1Char('?')))) + while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) || + title.endsWith(QLatin1Char('!')) || + title.endsWith(QLatin1Char('?')))) title.chop(1); if (title.size() < 2) { - qWarning() << "LlmClient: title rejected (too short)" - << title; + qWarning() << "LlmClient: title rejected (too short)" << title; return; } - if (title.size() > 48) - title = title.left(47) + QStringLiteral("…"); + if (title.size() > 48) title = title.left(47) + QStringLiteral("…"); qInfo() << "LlmClient: suggesting title" << title; Q_EMIT titleSuggested(session, title); }); @@ -813,13 +726,11 @@ void LlmClient::requestTitle(ChatSession* session, const QString& userText) { void LlmClient::requestIcon(ChatSession* session, const QString& userText) { const QStringList icons = { - "chat", "lightbulb", "code", - "description", "article", "school", - "work", "build", "science", - "palette", "music_note", "sports_esports", - "takeout_dining", "flight", "photo_camera", - "psychology_alt", "favorite", "savings", - "gamepad", "auto_awesome", + "chat", "lightbulb", "code", "description", + "article", "school", "work", "build", + "science", "palette", "music_note", "sports_esports", + "takeout_dining", "flight", "photo_camera", "psychology_alt", + "favorite", "savings", "gamepad", "auto_awesome", }; const QString prompt = QStringLiteral( @@ -831,8 +742,7 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) { QStringLiteral("icon"), prompt, userText, - [this, session = QPointer(session), icons]( - QString name) { + [this, session = QPointer(session), icons](QString name) { if (!session) { qWarning() << "LlmClient: icon request: session gone"; return; @@ -842,12 +752,12 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) { return c == QLatin1Char('"') || c == QLatin1Char('\''); }; while (name.size() >= 2 && isQuote(name.at(0)) && - isQuote(name.at(name.size() - 1))) + isQuote(name.at(name.size() - 1))) name = name.mid(1, name.size() - 2).simplified(); name.replace(QLatin1Char(' '), QLatin1Char('_')); if (!icons.contains(name)) { qWarning() << "LlmClient: icon not in list, using default" - << name; + << name; name = QStringLiteral("chat"); } qInfo() << "LlmClient: suggesting icon" << name; diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index 1397d57..56fdfc4 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -23,9 +23,6 @@ class ChatSession; class LlmSegment; class LlmTool; -// The only component that talks to the LLM server: owns the network -// manager, the in-flight streaming state, the SSE parsing and the -// tool-calling loop. class LlmClient : public QObject { Q_OBJECT @@ -53,17 +50,10 @@ class LlmClient : public QObject { [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } [[nodiscard]] ChatSession* streamingSession() const { return m_active; } - // Streams a new assistant reply into `target` (the active generation - // of a message of `session`). The context sent to the model is every - // message of the session that is older than the target message. - // Tool calls made by the model are executed and fed back - // transparently until the model produces its final answer. void startGeneration(ChatSession* session, ChatGeneration* target); void stop(); void endStream(); - // Clears the session's conversation once the current stream ends. void clearOnFinish(ChatSession* session); - // A session is about to be destroyed; drop any state pointing at it. void sessionRemoved(ChatSession* session); void refreshModels(); @@ -91,23 +81,13 @@ class LlmClient : public QObject { bool seen = false; }; - // Sends one streaming round: context + transcript so far. void sendRound(); - // The session context for a round, oldest first, ending just before - // `stopBeforeRow` (the message of the generation being streamed). QJsonArray buildContextMessages( ChatSession* session, int stopBeforeRow) const; void applyToolCallDelta(const QJsonObject& call); - // One round's stream ended; either ends the turn or executes the - // requested tool calls and sends the next round. Runs at most once - // per round ([DONE] and the reply's finished signal both reach it). void roundFinished(); - // Dispatches every call of the round; tools run concurrently. void executeAllCalls(); - // All results in: appends the tool messages (in call order) and - // sends the next round. void flushCallResults(); - // Ends the current turn gracefully and persists the session. void finishTurn(); void fail(const QString& message); void handleLine(const QByteArray& line); @@ -120,7 +100,8 @@ class LlmClient : public QObject { const QString& systemPrompt, const QString& userText, std::function onResult); - static QString completionsPath(const QString& endpoint, const QString& subpath); + static QString completionsPath( + const QString& endpoint, const QString& subpath); static QString serverErrorMessage( const QByteArray& body, const QString& fallback); @@ -139,7 +120,6 @@ class LlmClient : public QObject { double m_temperature = 0.7; int m_contextSize = 0; - // State of the multi-round tool loop of the current turn. QJsonArray m_transcript; QList m_callBuilders; struct ToolCallResult { diff --git a/Plugins/ZShell/Llm/markdownblock.hpp b/Plugins/ZShell/Llm/markdownblock.hpp index cbc6408..7242ed3 100644 --- a/Plugins/ZShell/Llm/markdownblock.hpp +++ b/Plugins/ZShell/Llm/markdownblock.hpp @@ -5,9 +5,6 @@ namespace ZShell::llm { -// Marker class exposing the LlmMarkdown block type enum to QML -// (LlmMarkdown.Type.*). Blocks themselves are value maps produced by -// MarkdownParser and stored on LlmSegment. class LlmMarkdown : public QObject { Q_OBJECT QML_ELEMENT @@ -16,9 +13,9 @@ class LlmMarkdown : public QObject { public: enum class Type : int { Text = 0, // Paragraph, list, quote, table; "text" is markdown source - Heading, // "level" + "text" (markdown source of the content) - Code, // "language" + "code" - Math // "latex" (display math, without the $$ delimiters) + Heading, // "level" + "text" (markdown source of the content) + Code, // "language" + "code" + Math // "latex" (display math, without the $$ delimiters) }; Q_ENUM(Type) diff --git a/Plugins/ZShell/Llm/markdownparser.cpp b/Plugins/ZShell/Llm/markdownparser.cpp index 3b9ab7d..756334d 100644 --- a/Plugins/ZShell/Llm/markdownparser.cpp +++ b/Plugins/ZShell/Llm/markdownparser.cpp @@ -13,15 +13,12 @@ namespace ZShell::llm { QVariantList MarkdownParser::parse(const QString& source) { QVariantList blocks; - if (source.trimmed().isEmpty()) - return blocks; + if (source.trimmed().isEmpty()) return blocks; const QStringList lines = source.split('\n'); - // cmark-gfm line/column numbers are 1-based and inclusive. auto sliceSource = [&](int startLine, int endLine) -> QString { - if (startLine < 1 || endLine < startLine) - return QString(); + if (startLine < 1 || endLine < startLine) return QString(); const int from = startLine; const int to = qMin(endLine, static_cast(lines.size())); return lines.mid(from - 1, to - from + 1).join('\n').trimmed(); @@ -29,15 +26,11 @@ QVariantList MarkdownParser::parse(const QString& source) { const QByteArray utf8 = source.toUtf8(); cmark_node* doc = cmark_parse_document( - utf8.constData(), static_cast(utf8.size()), + utf8.constData(), + static_cast(utf8.size()), CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS); - if (!doc) - return blocks; + if (!doc) return blocks; - // cmark-gfm has no math extension, so $$...$$ parses as an ordinary - // paragraph. Re-detect display math (a $$...$$ pair) here and split it - // out as its own block. Single-$ inline math is intentionally left - // untouched (rendered raw) for now. const QRegularExpression mathRe( QStringLiteral("\\$\\$(.+?)\\$\\$"), QRegularExpression::DotMatchesEverythingOption); @@ -45,29 +38,22 @@ QVariantList MarkdownParser::parse(const QString& source) { auto makeBlock = [&](LlmMarkdown::Type type) { QVariantMap block; block.insert("type", static_cast(type)); - // Stable per-position identity ("index:type") for the QML - // ScriptModel: blocks that survive a re-parse keep their id, so - // their delegates are updated in place instead of recreated - // (which would drop code highlights mid-stream). The type is - // part of the id so a block that changes type is rebuilt. - block.insert("id", - QString::number(static_cast(blocks.size())) - + QLatin1Char(':') - + QString::number(static_cast(type))); + block.insert( + "id", + QString::number(static_cast(blocks.size())) + + QLatin1Char(':') + QString::number(static_cast(type))); return block; }; auto appendText = [&](const QString& text) { - if (text.trimmed().isEmpty()) - return; + if (text.trimmed().isEmpty()) return; QVariantMap block = makeBlock(LlmMarkdown::Type::Text); block.insert("text", text); blocks.append(block); }; auto appendMath = [&](const QString& latex) { - if (latex.trimmed().isEmpty()) - return; + if (latex.trimmed().isEmpty()) return; QVariantMap block = makeBlock(LlmMarkdown::Type::Math); block.insert("latex", latex); blocks.append(block); @@ -81,8 +67,7 @@ QVariantList MarkdownParser::parse(const QString& source) { }; auto appendHeading = [&](int level, const QString& text) { - if (text.trimmed().isEmpty()) - return; + if (text.trimmed().isEmpty()) return; QVariantMap block = makeBlock(LlmMarkdown::Type::Heading); block.insert("level", level); block.insert("text", text); @@ -90,7 +75,7 @@ QVariantList MarkdownParser::parse(const QString& source) { }; for (cmark_node* node = cmark_node_first_child(doc); node; - node = cmark_node_next(node)) { + node = cmark_node_next(node)) { const cmark_node_type type = cmark_node_get_type(node); const int startLine = cmark_node_get_start_line(node); const int endLine = cmark_node_get_end_line(node); @@ -98,21 +83,20 @@ QVariantList MarkdownParser::parse(const QString& source) { if (type == CMARK_NODE_CODE_BLOCK) { const char* literal = cmark_node_get_literal(node); QString code = literal ? QString::fromUtf8(literal) : QString(); - // Fenced block literals carry a trailing newline; drop one. - if (code.endsWith('\n')) - code.chop(1); + if (code.endsWith('\n')) code.chop(1); QString language; if (const char* info = cmark_node_get_fence_info(node); info) - language = QString::fromUtf8(info).section(' ', 0, 0).trimmed().toLower(); + language = QString::fromUtf8(info) + .section(' ', 0, 0) + .trimmed() + .toLower(); appendCode(language, code); continue; } if (type == CMARK_NODE_HEADING) { - // Inline content only (no leading #), so QML can style by - // level. const char* content = cmark_node_get_string_content(node); appendHeading( cmark_node_get_heading_level(node), @@ -125,11 +109,11 @@ QVariantList MarkdownParser::parse(const QString& source) { int cursor = 0; bool anyMath = false; for (auto it = mathRe.globalMatch(text, cursor); it.hasNext(); - it = mathRe.globalMatch(text, cursor)) { + it = mathRe.globalMatch(text, cursor)) { const QRegularExpressionMatch m = it.next(); anyMath = true; - appendText( - text.mid(cursor, static_cast(m.capturedStart() - cursor))); + appendText(text.mid( + cursor, static_cast(m.capturedStart() - cursor))); appendMath(m.captured(1).trimmed()); cursor = static_cast(m.capturedEnd()); } @@ -140,9 +124,6 @@ QVariantList MarkdownParser::parse(const QString& source) { continue; } - // Lists, block quotes, tables, horizontal rules, custom blocks: - // hand the raw markdown source to QML (rendered via - // Text.MarkdownText). appendText(sliceSource(startLine, endLine)); } diff --git a/Plugins/ZShell/Llm/markdownparser.hpp b/Plugins/ZShell/Llm/markdownparser.hpp index ea739d7..433e06b 100644 --- a/Plugins/ZShell/Llm/markdownparser.hpp +++ b/Plugins/ZShell/Llm/markdownparser.hpp @@ -5,18 +5,6 @@ namespace ZShell::llm { -// Splits a markdown document into top-level blocks for QML rendering -// (cmark-gfm AST walk). Inline structure is not flattened; Text and -// Heading blocks carry markdown source that QML renders with -// Text.MarkdownText. -// -// Block map keys: -// "type" int (LlmMarkdown::Type) -// "level" int (Heading) -// "language" QString (Code, lowercased, empty when unknown) -// "code" QString (Code) -// "text" QString (Text/Heading, markdown source) -// "latex" QString (Math, without the $$ delimiters) class MarkdownParser { public: [[nodiscard]] static QVariantList parse(const QString& source); diff --git a/Plugins/ZShell/Llm/mathtext.cpp b/Plugins/ZShell/Llm/mathtext.cpp index 975f68e..8abcede 100644 --- a/Plugins/ZShell/Llm/mathtext.cpp +++ b/Plugins/ZShell/Llm/mathtext.cpp @@ -16,15 +16,10 @@ namespace ZShell::llm { namespace { -// A few px of breathing room around the equation. constexpr int kRenderMargin = 2; constexpr unsigned int kResolutionDpi = 96; -// The render cache is unbounded between clears; cap it so a very long -// session with many distinct equations cannot grow it forever. constexpr int kCacheLimit = 512; -// Registers the embedded Latin Modern faces with the font database -// (once per process) and reports which families became available. struct LatinModern { bool roman = false; bool math = false; @@ -33,37 +28,44 @@ struct LatinModern { const LatinModern& loadLatinModern() { static const LatinModern fonts = [] { LatinModern result; - // addApplicationFont(QByteArray) fails in this environment, so - // the embedded bytes are staged to a per-process temp file and - // registered through the (stable) file-based API. - const auto add = [](const unsigned char* data, size_t size, - const QString& fileName, const QString& family) { + const auto add = [](const unsigned char* data, + size_t size, + const QString& fileName, + const QString& family) { const QString path = QDir::tempPath() + QLatin1Char('/') + fileName; { QFile f(path); if (!f.open(QIODevice::WriteOnly) || - f.write(reinterpret_cast(data), - static_cast(size)) != static_cast(size)) + f.write( + reinterpret_cast(data), + static_cast(size)) != static_cast(size)) return false; } const int key = QFontDatabase::addApplicationFont(path); - if (key < 0) - return false; + if (key < 0) return false; return QFontDatabase::applicationFontFamilies(key).contains(family); }; - result.roman = - add(lmfont::lmroman10_regular, sizeof(lmfont::lmroman10_regular), - QStringLiteral("lmroman10-regular.otf"), QStringLiteral("LMRoman10")) - && add(lmfont::lmroman10_italic, sizeof(lmfont::lmroman10_italic), - QStringLiteral("lmroman10-italic.otf"), QStringLiteral("LMRoman10")) - && add(lmfont::lmroman10_bold, sizeof(lmfont::lmroman10_bold), - QStringLiteral("lmroman10-bold.otf"), QStringLiteral("LMRoman10")) - && add(lmfont::lmroman10_bolditalic, sizeof(lmfont::lmroman10_bolditalic), - QStringLiteral("lmroman10-bolditalic.otf"), - QStringLiteral("LMRoman10")); - result.math = add( - lmfont::latinmodern_math, sizeof(lmfont::latinmodern_math), - QStringLiteral("latinmodern-math.otf"), QStringLiteral("Latin Modern Math")); + result.roman = add(lmfont::lmroman10_regular, + sizeof(lmfont::lmroman10_regular), + QStringLiteral("lmroman10-regular.otf"), + QStringLiteral("LMRoman10")) && + add(lmfont::lmroman10_italic, + sizeof(lmfont::lmroman10_italic), + QStringLiteral("lmroman10-italic.otf"), + QStringLiteral("LMRoman10")) && + add(lmfont::lmroman10_bold, + sizeof(lmfont::lmroman10_bold), + QStringLiteral("lmroman10-bold.otf"), + QStringLiteral("LMRoman10")) && + add(lmfont::lmroman10_bolditalic, + sizeof(lmfont::lmroman10_bolditalic), + QStringLiteral("lmroman10-bolditalic.otf"), + QStringLiteral("LMRoman10")); + result.math = + add(lmfont::latinmodern_math, + sizeof(lmfont::latinmodern_math), + QStringLiteral("latinmodern-math.otf"), + QStringLiteral("Latin Modern Math")); return result; }(); return fonts; @@ -72,22 +74,17 @@ const LatinModern& loadLatinModern() { } // namespace LlmMathText::LlmMathText(QObject* parent) -// No parent: the renderer is used from pool threads, and a parented -// QObject would taint children it creates there. -: QObject(parent), m_renderer(std::make_shared( - nullptr, /* useFontsForGUI */ true)) { - // Latin Modern is the default font of modern LaTeX; use the embedded - // faces instead of whatever the system happens to have installed. + : QObject(parent) + , m_renderer( + std::make_shared(nullptr, /* useFontsForGUI */ true)) { const LatinModern& fonts = loadLatinModern(); if (fonts.roman) - m_renderer->setFontRomanAndMath(QStringLiteral("LMRoman10"), - JKQTMathTextFontEncoding::MTFEUnicode); + m_renderer->setFontRomanAndMath( + QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode); if (fonts.math) { - // Same pattern as JKQTMathText's useXITS(): the OpenType math - // font supplies the math alphabet and operators from its MATH - // table. - m_renderer->setFontMathRoman(QStringLiteral("Latin Modern Math"), - JKQTMathTextFontEncoding::MTFEUnicode); + m_renderer->setFontMathRoman( + QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); m_renderer->setFallbackFontSymbols( QStringLiteral("Latin Modern Math"), JKQTMathTextFontEncoding::MTFEUnicode); @@ -95,38 +92,31 @@ LlmMathText::LlmMathText(QObject* parent) } void LlmMathText::setLatex(const QString& value) { - if (m_latex == value) - return; + if (m_latex == value) return; m_latex = value; reRender(); } void LlmMathText::setColor(const QColor& value) { - if (m_color == value) - return; + if (m_color == value) return; m_color = value; reRender(); } void LlmMathText::setFontPointSize(double value) { - if (qFuzzyCompare(m_fontPointSize, value)) - return; + if (qFuzzyCompare(m_fontPointSize, value)) return; m_fontPointSize = value; reRender(); } void LlmMathText::setDevicePixelRatio(qreal value) { - if (qFuzzyCompare(m_devicePixelRatio, value)) - return; + if (qFuzzyCompare(m_devicePixelRatio, value)) return; m_devicePixelRatio = value; reRender(); } namespace { -// Process-wide render cache; the key is the full render state, so -// re-opening a chat reuses already-rendered equations. Accessed on the -// GUI thread only. struct MathRender { bool ok = false; QImage image; @@ -154,10 +144,9 @@ void LlmMathText::reRender() { return; } - const QString key = m_latex - + QLatin1Char(0x1f) + m_color.name() - + QLatin1Char(0x1f) + QString::number(m_fontPointSize) - + QLatin1Char(0x1f) + QString::number(m_devicePixelRatio); + const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() + + QLatin1Char(0x1f) + QString::number(m_fontPointSize) + + QLatin1Char(0x1f) + QString::number(m_devicePixelRatio); if (auto it = mathCache().find(key); it != mathCache().end()) { m_image = it->image; m_imageUrl = it->url; @@ -168,83 +157,70 @@ void LlmMathText::reRender() { return; } - // A render is already running; it re-renders the latest state when - // it completes (id mismatch), so there is nothing to do here. - if (m_inFlight) - return; + if (m_inFlight) return; m_inFlight = true; const QString latex = m_latex; const QColor color = m_color; const double pointSize = m_fontPointSize; const qreal dpr = m_devicePixelRatio; - // The worker captures the renderer by value (shared_ptr) so it - // stays alive even if this object is destroyed mid-render; it is - // only ever used by the single in-flight worker (m_inFlight), - // never concurrently. auto renderer = m_renderer; - QThreadPool::globalInstance()->start([this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() { - MathRender render; - renderer->setFontPointSize(pointSize); - renderer->setFontColor(color); - if (renderer->parse( - latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) { - const QImage image = renderer->drawIntoImage( - /* drawBoxes */ false, - QColor(Qt::transparent), - kRenderMargin, - dpr, - kResolutionDpi); - if (!image.isNull()) { - QByteArray png; - { - QBuffer buffer(&png); - buffer.open(QIODevice::WriteOnly); - image.save(&buffer, "PNG"); + QThreadPool::globalInstance()->start( + [this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() { + MathRender render; + renderer->setFontPointSize(pointSize); + renderer->setFontColor(color); + if (renderer->parse( + latex, + JKQTMathText::LatexParser, + JKQTMathText::DefaultParseOptions)) { + const QImage image = renderer->drawIntoImage( + /* drawBoxes */ false, + QColor(Qt::transparent), + kRenderMargin, + dpr, + kResolutionDpi); + if (!image.isNull()) { + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + } + render.image = image; + render.url = QUrl( + QStringLiteral("data:image/png;base64,") + + QString::fromLatin1(png.toBase64())); + render.width = image.width() / dpr; + render.height = image.height() / dpr; + render.ok = true; } - render.image = image; - render.url = QUrl( - QStringLiteral("data:image/png;base64,") - + QString::fromLatin1(png.toBase64())); - // drawIntoImage renders at devicePixelRatio; convert - // back to logical pixels. - render.width = image.width() / dpr; - render.height = image.height() / dpr; - render.ok = true; } - } - // Deliver through the app instance (never destroyed) and - // re-check the pointer on the GUI thread: posting to `this` - // from the pool thread would race with its destruction. - QPointer guard(this); - QMetaObject::invokeMethod( - QCoreApplication::instance(), - [guard, id, key, render = std::move(render)]() mutable { - LlmMathText* self = guard; - if (!self) - return; - self->m_inFlight = false; - if (id != self->m_requestId) { - // Superseded while the worker ran; render the - // latest state. - self->reRender(); - return; - } - if (render.ok) { - auto& cache = mathCache(); - if (cache.size() >= kCacheLimit) - cache.clear(); - cache.insert(key, render); - } - self->m_image = render.image; - self->m_imageUrl = render.url; - self->m_width = render.width; - self->m_height = render.height; - self->m_ok = render.ok; - Q_EMIT self->changed(); - }, - Qt::QueuedConnection); - }); + QPointer guard(this); + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [guard, id, key, render = std::move(render)]() mutable { + LlmMathText* self = guard; + if (!self) return; + self->m_inFlight = false; + if (id != self->m_requestId) { + self->reRender(); + return; + } + if (render.ok) { + auto& cache = mathCache(); + if (cache.size() >= kCacheLimit) cache.clear(); + cache.insert(key, render); + } + self->m_image = render.image; + self->m_imageUrl = render.url; + self->m_width = render.width; + self->m_height = render.height; + self->m_ok = render.ok; + Q_EMIT self->changed(); + }, + Qt::QueuedConnection); + }); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.hpp b/Plugins/ZShell/Llm/mathtext.hpp index ad25613..3f97a59 100644 --- a/Plugins/ZShell/Llm/mathtext.hpp +++ b/Plugins/ZShell/Llm/mathtext.hpp @@ -13,27 +13,20 @@ namespace ZShell::llm { -// QML wrapper around JKQTMathText (JKQtPlotter's LaTeX renderer). -// -// Parses a display-math string and renders it into a transparent -// QImage at the given device pixel ratio, off the GUI thread (with a -// process-wide cache keyed on the full render state, so re-opening a -// chat does not re-render the same equations). QML displays the image -// (scaling it to the bubble width when needed) and falls back to the -// raw LaTeX when parsing fails. class LlmMathText : public QObject { Q_OBJECT QML_ELEMENT Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed) Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed) - Q_PROPERTY(double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY changed) - Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio NOTIFY changed) + Q_PROPERTY( + double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY + changed) + Q_PROPERTY( + qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio + NOTIFY changed) Q_PROPERTY(QImage image READ image NOTIFY changed) - // data: URL of the rendered equation; usable directly as - // Image.source (a raw QImage is not). Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed) - // Logical (CSS pixel) size of the rendered equation. Q_PROPERTY(qreal width READ width NOTIFY changed) Q_PROPERTY(qreal height READ height NOTIFY changed) Q_PROPERTY(bool ok READ ok NOTIFY changed) @@ -61,8 +54,6 @@ class LlmMathText : public QObject { private: void reRender(); - // Shared so an in-flight worker render keeps the renderer alive if - // this object (and its QML item) is destroyed mid-render. std::shared_ptr m_renderer; QString m_latex; QColor m_color; @@ -73,8 +64,6 @@ class LlmMathText : public QObject { qreal m_width = 0; qreal m_height = 0; bool m_ok = false; - // Bumps on every reRender; a delivery carrying an older id was - // superseded and is dropped. int m_requestId = 0; bool m_inFlight = false; }; diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index 6c5f2b5..070b3cb 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -16,13 +16,12 @@ ChatSession* sessionOf(const ChatMessage* message) { } // namespace ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) -: QObject(parent), m_role(role), m_timestamp(timestamp) {} + : QObject(parent), m_role(role), m_timestamp(timestamp) {} ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { auto* generation = new ChatGeneration(timestamp, this); m_generations.append(generation); - if (m_active < 0) - m_active = static_cast(m_generations.size() - 1); + if (m_active < 0) m_active = static_cast(m_generations.size() - 1); Q_EMIT generationsChanged(); return generation; } @@ -35,8 +34,7 @@ ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) { void ChatMessage::removeGeneration(ChatGeneration* generation) { const int index = static_cast(m_generations.indexOf(generation)); - if (index < 0) - return; + if (index < 0) return; const bool wasActive = index == m_active; m_generations.removeAt(index); delete generation; @@ -46,13 +44,11 @@ void ChatMessage::removeGeneration(ChatGeneration* generation) { m_active = static_cast(m_generations.size() - 1); } Q_EMIT generationsChanged(); - if (wasActive) - Q_EMIT activeGenerationChanged(); + if (wasActive) Q_EMIT activeGenerationChanged(); } void ChatMessage::setActiveInternal(int index) { - if (index < 0 || index >= m_generations.size() || index == m_active) - return; + if (index < 0 || index >= m_generations.size() || index == m_active) return; m_active = index; Q_EMIT activeGenerationChanged(); } @@ -64,18 +60,15 @@ void ChatMessage::setActiveGeneration(int index) { void ChatMessage::edit(const QString& newContent) { if (auto* generation = activeGeneration()) generation->setContent(newContent); - if (auto* session = sessionOf(this)) - session->persist(); + if (auto* session = sessionOf(this)) session->persist(); } void ChatMessage::retry() { - if (auto* session = sessionOf(this)) - session->retry(this); + if (auto* session = sessionOf(this)) session->retry(this); } void ChatMessage::generate() { - if (auto* session = sessionOf(this)) - session->continueFrom(this); + if (auto* session = sessionOf(this)) session->continueFrom(this); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp index 5fbaaf5..87310ed 100644 --- a/Plugins/ZShell/Llm/message.hpp +++ b/Plugins/ZShell/Llm/message.hpp @@ -16,24 +16,23 @@ class ChatMessage : public QObject { Q_PROPERTY(Role role READ role CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) - Q_PROPERTY(int generationCount READ generationCount NOTIFY generationsChanged) Q_PROPERTY( - QList generations READ generations - NOTIFY generationsChanged) + int generationCount READ generationCount NOTIFY generationsChanged) + Q_PROPERTY( + QList generations READ generations NOTIFY + generationsChanged) Q_PROPERTY( ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration NOTIFY activeGenerationChanged) - Q_PROPERTY(int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged) + Q_PROPERTY( + int activeGenerationIndex READ activeGenerationIndex NOTIFY + activeGenerationChanged) public: - enum class Role : int { - User = 0, - Assistant - }; + enum class Role : int { User = 0, Assistant }; Q_ENUM(Role) - explicit ChatMessage( - Role role, qint64 timestamp, QObject* parent = nullptr); + explicit ChatMessage(Role role, qint64 timestamp, QObject* parent = nullptr); [[nodiscard]] Role role() const { return m_role; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } @@ -48,8 +47,7 @@ class ChatMessage : public QObject { } [[nodiscard]] int activeGenerationIndex() const { return m_active; } [[nodiscard]] ChatGeneration* generation(int index) const { - if (index < 0 || index >= m_generations.size()) - return nullptr; + if (index < 0 || index >= m_generations.size()) return nullptr; return m_generations.at(index); } @@ -58,7 +56,6 @@ class ChatMessage : public QObject { Q_INVOKABLE void retry(); Q_INVOKABLE void generate(); - // Creates an empty generation; callers fill it with segments. ChatGeneration* addGeneration(qint64 timestamp); ChatGeneration* appendGeneration(qint64 timestamp); void removeGeneration(ChatGeneration* generation); diff --git a/Plugins/ZShell/Llm/messagemodel.cpp b/Plugins/ZShell/Llm/messagemodel.cpp index d02c44a..9dd50c8 100644 --- a/Plugins/ZShell/Llm/messagemodel.cpp +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -100,11 +100,9 @@ void ChatMessageModel::clear() { void ChatMessageModel::loadMessages(QList messages) { beginResetModel(); - // The new list may share rows with the current one (live rows kept - // in front of fetched rows); delete only what is truly gone. for (ChatMessage* message : m_messages) - if (std::find(messages.begin(), messages.end(), message) - == messages.end()) + if (std::find(messages.begin(), messages.end(), message) == + messages.end()) delete message; m_messages = std::move(messages); endResetModel(); diff --git a/Plugins/ZShell/Llm/messagemodel.hpp b/Plugins/ZShell/Llm/messagemodel.hpp index 3c7f44b..016c5a6 100644 --- a/Plugins/ZShell/Llm/messagemodel.hpp +++ b/Plugins/ZShell/Llm/messagemodel.hpp @@ -12,17 +12,14 @@ namespace ZShell::llm { class ChatSession; -// Owns a session's messages, most recent first: row 0 is always the newest -// message. Items are exposed through a role named "modelData" (like -// FileSystemModel), so delegates receive each ChatMessage as `modelData`. class ChatMessageModel : public QAbstractListModel { Q_OBJECT QML_ELEMENT QML_UNCREATABLE("Chat message models are owned by ChatSession") Q_PROPERTY( - ZShell::llm::ChatMessage* lastMessage READ lastMessage - NOTIFY lastMessageChanged) + ZShell::llm::ChatMessage* lastMessage READ lastMessage NOTIFY + lastMessageChanged) public: explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr); @@ -35,7 +32,6 @@ class ChatMessageModel : public QAbstractListModel { [[nodiscard]] QHash roleNames() const override; [[nodiscard]] ChatSession* session() const { return m_session; } - // Most recent message first. [[nodiscard]] QList messages() const { return m_messages; } [[nodiscard]] ChatMessage* at(int row) const; [[nodiscard]] int rowOf(const ChatMessage* message) const; @@ -44,17 +40,12 @@ class ChatMessageModel : public QAbstractListModel { return m_messages.isEmpty() ? nullptr : m_messages.first(); } - // Creates a message owned by this model without inserting it. ChatMessage* createMessage(ChatMessage::Role role, qint64 timestamp); - // Appends a new message as the newest one (row 0) with a single - // generation holding `content`. ChatMessage* appendNewest( ChatMessage::Role role, const QString& content, qint64 timestamp); void removeMessage(ChatMessage* message); void removeRange(int firstRow, int lastRow); void clear(); - // Replaces every row; takes ownership of the given messages, most recent - // first. void loadMessages(QList messages); signals: diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp index 72bf249..e75087c 100644 --- a/Plugins/ZShell/Llm/segment.cpp +++ b/Plugins/ZShell/Llm/segment.cpp @@ -10,36 +10,27 @@ namespace ZShell::llm { namespace { -// Re-parse cadence while a segment streams; content between refreshes is -// at most this stale. constexpr int kMarkdownRefreshMs = 150; } // namespace LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) -: QObject(parent), m_type(type), m_timestamp(timestamp) { + : QObject(parent), m_type(type), m_timestamp(timestamp) { m_markdownTimer.setInterval(kMarkdownRefreshMs); - connect( - &m_markdownTimer, &QTimer::timeout, this, [this]() { - if (!m_markdownDirty) - return; - // A parse may still be running; leave the dirty flag set so - // it re-parses the newest text when that one completes. - if (parseMarkdown()) - m_markdownDirty = false; - }); + connect(&m_markdownTimer, &QTimer::timeout, this, [this]() { + if (!m_markdownDirty) return; + if (parseMarkdown()) m_markdownDirty = false; + }); } qint64 LlmSegment::elapsedMs() const { - if (m_startedAt <= 0) - return 0; + if (m_startedAt <= 0) return 0; const qint64 end = m_endedAt > 0 ? m_endedAt - : QDateTime::currentMSecsSinceEpoch(); + : QDateTime::currentMSecsSinceEpoch(); return end - m_startedAt; } void LlmSegment::begin() { - if (m_running) - return; + if (m_running) return; m_running = true; m_startedAt = QDateTime::currentMSecsSinceEpoch(); m_endedAt = 0; @@ -54,62 +45,52 @@ void LlmSegment::close() { Q_EMIT runningChanged(); Q_EMIT elapsedMsChanged(); } - // The segment is final; parse immediately so the UI does not wait - // out the debounce. if (m_type == Type::Content && m_markdownDirty) { m_markdownTimer.stop(); - if (parseMarkdown()) - m_markdownDirty = false; + if (parseMarkdown()) m_markdownDirty = false; } } void LlmSegment::appendText(const QString& piece) { - if (piece.isEmpty()) - return; + if (piece.isEmpty()) return; m_text += piece; Q_EMIT textChanged(); scheduleMarkdown(); } void LlmSegment::setText(const QString& value) { - if (m_text == value) - return; + if (m_text == value) return; m_text = value; Q_EMIT textChanged(); scheduleMarkdown(); } void LlmSegment::setName(const QString& value) { - if (m_name == value) - return; + if (m_name == value) return; m_name = value; Q_EMIT nameChanged(); } void LlmSegment::setToolCallId(const QString& value) { - if (m_toolCallId == value) - return; + if (m_toolCallId == value) return; m_toolCallId = value; Q_EMIT toolCallIdChanged(); } void LlmSegment::appendArguments(const QString& piece) { - if (piece.isEmpty()) - return; + if (piece.isEmpty()) return; m_arguments += piece; Q_EMIT argumentsChanged(); } void LlmSegment::setResult(const QString& value) { - if (m_result == value) - return; + if (m_result == value) return; m_result = value; Q_EMIT resultChanged(); } void LlmSegment::setStatus(Status value) { - if (m_status == value) - return; + if (m_status == value) return; m_status = value; Q_EMIT statusChanged(); } @@ -126,39 +107,27 @@ void LlmSegment::restore(qint64 elapsedMs) { } void LlmSegment::scheduleMarkdown() { - // Content segments only; reasoning/tool output is never parsed. - // (User content is parsed too, so it can later be rendered as blocks - // as well; the QML currently only does that for assistant messages.) - if (m_type != Type::Content) - return; + if (m_type != Type::Content) return; m_markdownDirty = true; - if (!m_markdownTimer.isActive()) - m_markdownTimer.start(); + if (!m_markdownTimer.isActive()) m_markdownTimer.start(); } bool LlmSegment::parseMarkdown() { - if (m_parseInFlight) - return false; + if (m_parseInFlight) return false; m_parseInFlight = true; const QString text = m_text; QThreadPool::globalInstance()->start([this, text]() { const QVariantList blocks = MarkdownParser::parse(text); - // Deliver through qApp (never destroyed) and re-check the - // pointer on the GUI thread: posting to `this` from the pool - // thread would race with its destruction. QPointer guard(this); QMetaObject::invokeMethod( QCoreApplication::instance(), [guard, blocks]() { LlmSegment* seg = guard; - if (!seg) - return; + if (!seg) return; seg->m_parseInFlight = false; seg->m_markdown = blocks; Q_EMIT seg->markdownChanged(); - // Text arrived while the worker was running; parse it. - if (seg->m_markdownDirty) - seg->parseMarkdown(); + if (seg->m_markdownDirty) seg->parseMarkdown(); }, Qt::QueuedConnection); }); diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp index 949f78b..ce125d1 100644 --- a/Plugins/ZShell/Llm/segment.hpp +++ b/Plugins/ZShell/Llm/segment.hpp @@ -8,10 +8,6 @@ namespace ZShell::llm { -// One unit of activity within a ChatGeneration. A generation keeps its -// segments in chronological order: zero or more reasoning bursts and -// tool calls interleaved, plus at most one content segment holding the -// final answer. class LlmSegment : public QObject { Q_OBJECT QML_ELEMENT @@ -27,26 +23,13 @@ class LlmSegment : public QObject { Q_PROPERTY(Status status READ status NOTIFY statusChanged) Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged) - // Parsed markdown blocks (QVariantList of maps, see MarkdownParser). - // Only Content segments are parsed. Refreshes are debounced so a - // streaming segment re-parses at a steady cadence rather than per - // chunk. Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged) public: - enum class Type : int { - Reasoning = 0, - ToolCall, - Content - }; + enum class Type : int { Reasoning = 0, ToolCall, Content }; Q_ENUM(Type) - enum class Status : int { - None = 0, - Running, - Success, - Error - }; + enum class Status : int { None = 0, Running, Success, Error }; Q_ENUM(Status) explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); @@ -63,9 +46,7 @@ class LlmSegment : public QObject { [[nodiscard]] qint64 elapsedMs() const; [[nodiscard]] QVariantList markdown() const { return m_markdown; } - // Starts the segment's clock; a no-op while already running. void begin(); - // Stops the segment's clock; a no-op when not running. void close(); void appendText(const QString& piece); void setText(const QString& value); @@ -74,9 +55,7 @@ class LlmSegment : public QObject { void appendArguments(const QString& piece); void setResult(const QString& value); void setStatus(Status value); - // Completes a tool call with the model-facing result text. void finishTool(const QString& resultText, bool success); - // Restores persisted timing without a live clock. void restore(qint64 elapsedMs); Q_SIGNALS: @@ -92,8 +71,6 @@ class LlmSegment : public QObject { private: void scheduleMarkdown(); - // Kicks off an off-thread parse; returns false when one is already - // in flight (the pending change is picked up when it completes). bool parseMarkdown(); Type m_type; diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index d6287d8..2f083ba 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -145,10 +145,10 @@ void ChatSession::startGeneration(ChatMessage* target) { if (isLoaded()) { clientObject->startGeneration(this, generation); } else { - // The store load is still in flight; the request needs the - // full history, so start once it lands. connect( - this, &ChatSession::loaded, clientObject, + this, + &ChatSession::loaded, + clientObject, [this, generation, clientObject]() { clientObject->startGeneration(this, generation); }); @@ -192,8 +192,6 @@ void ChatSession::retry(ChatMessage* target) { const int row = m_model->rowOf(target); if (row < 0) return; - // Drop everything newer than the target, then regenerate from the - // context ending at the user message before it. m_model->removeRange(0, row - 1); if (m_model->rowCount() < 2) return; @@ -233,7 +231,6 @@ void ChatSession::clear() { } } if (!isLoaded()) { - // The load lands shortly; drop everything once it does. m_clearPending = true; return; } diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index 5e97a78..a024322 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -44,14 +44,9 @@ class ChatSession : public QObject { [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] int messageCount() const { return m_messageCount; } - // The messages model; the first access starts the (async) load - // from the store. [[nodiscard]] ChatMessageModel* messagesModel(); void ensureLoaded(); - // True once the async load from the store has finished. [[nodiscard]] bool isLoaded() const { return m_loaded; } - // The model without triggering a load (ChatStore use during the - // load itself). [[nodiscard]] ChatMessageModel* model() const { return m_model; } void markLoaded(); [[nodiscard]] bool takeClearPending(); @@ -71,7 +66,6 @@ class ChatSession : public QObject { qint64 updatedAt, int messageCount); - // Replaces the model's rows with `messages` (most recent first). void adoptMessages(QList messages); void persist(); void removeMessage(ChatMessage* message); @@ -88,7 +82,6 @@ class ChatSession : public QObject { void updatedAtChanged(); void pinnedChanged(); void messageCountChanged(); - // The messages finished loading from the store. void loaded(); private: diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp index 15a9c25..05ddb35 100644 --- a/Plugins/ZShell/Llm/tool.cpp +++ b/Plugins/ZShell/Llm/tool.cpp @@ -22,29 +22,25 @@ QJsonObject LlmTool::specification() const { ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {} void ToolRegistry::setEnabled(bool value) { - if (m_enabled == value) - return; + if (m_enabled == value) return; m_enabled = value; Q_EMIT enabledChanged(); } void ToolRegistry::registerTool(LlmTool* tool) { - if (!tool || m_tools.contains(tool)) - return; + if (!tool || m_tools.contains(tool)) return; tool->setParent(this); m_tools.append(tool); } LlmTool* ToolRegistry::tool(const QString& name) const { for (const auto* tool : m_tools) - if (tool->name() == name) - return const_cast(tool); + if (tool->name() == name) return const_cast(tool); return nullptr; } QJsonArray ToolRegistry::specifications() const { - if (!m_enabled) - return {}; + if (!m_enabled) return {}; QJsonArray specs; for (const auto* tool : m_tools) specs.append(tool->specification()); diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp index ef2195c..f8e12df 100644 --- a/Plugins/ZShell/Llm/tool.hpp +++ b/Plugins/ZShell/Llm/tool.hpp @@ -10,9 +10,6 @@ namespace ZShell::llm { -// A capability the model may invoke mid-turn. Tools run asynchronously -// and report exactly one result: `{"output": ...}` on success or -// `{"error": ...}` on failure. class LlmTool : public QObject { Q_OBJECT @@ -22,24 +19,16 @@ class LlmTool : public QObject { [[nodiscard]] virtual QString name() const = 0; [[nodiscard]] virtual QString description() const = 0; - // JSON Schema describing the tool's `arguments` object. [[nodiscard]] virtual QJsonObject parameters() const = 0; - // Runs the tool; `done` is invoked exactly once, with - // `{"output": ...}` on success or `{"error": ...}` on failure. - // `done` must be invoked asynchronously (on a later event loop - // iteration), never synchronously within execute(). virtual void execute( const QJsonObject& args, std::function done) = 0; - // Abandons in-flight work, if any. virtual void cancel(); - // The OpenAI-compatible `tools` entry for this tool. [[nodiscard]] QJsonObject specification() const; }; -// Owns the set of tools available to the model. class ToolRegistry : public QObject { Q_OBJECT @@ -51,12 +40,9 @@ class ToolRegistry : public QObject { [[nodiscard]] bool enabled() const { return m_enabled; } void setEnabled(bool value); - // Takes ownership; tools become children of the registry. void registerTool(LlmTool* tool); [[nodiscard]] LlmTool* tool(const QString& name) const; - // The request body's `tools` array; empty while disabled. [[nodiscard]] QJsonArray specifications() const; - // Abandons in-flight work in every tool. void cancelAll(); Q_SIGNALS: diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp index bc573f8..ff86b8f 100644 --- a/Plugins/ZShell/Llm/webfetchtool.cpp +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -32,12 +32,9 @@ WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {} WebFetchTool::~WebFetchTool() { for (auto* job : m_jobs) { - if (job->timer) - job->timer->stop(); - if (job->reply) - job->reply->abort(); + if (job->timer) job->timer->stop(); + if (job->reply) job->reply->abort(); } - // Pending result callbacks are dropped; the client is going away. qDeleteAll(m_jobs); } @@ -64,9 +61,9 @@ QJsonObject WebFetchTool::parameters() const { QJsonObject format; format[QStringLiteral("type")] = QStringLiteral("string"); format[QStringLiteral("enum")] = formats; - format[QStringLiteral("description")] = - QStringLiteral("The format to return the content in. Defaults to " - "text."); + format[QStringLiteral("description")] = QStringLiteral( + "The format to return the content in. Defaults to " + "text."); QJsonObject timeout; timeout[QStringLiteral("type")] = QStringLiteral("integer"); @@ -90,18 +87,14 @@ QJsonObject WebFetchTool::parameters() const { } void WebFetchTool::completeJob(Job* job, QJsonObject result) { - // Deliver on a later event loop iteration; LlmClient relies on tool - // results never arriving synchronously within execute(). QMetaObject::invokeMethod( this, [this, job, result = std::move(result)]() mutable { - if (!m_jobs.contains(job)) - return; + if (!m_jobs.contains(job)) return; auto done = std::move(job->done); m_jobs.removeAll(job); job->timer->deleteLater(); - if (job->reply) - job->reply->deleteLater(); + if (job->reply) job->reply->deleteLater(); delete job; done(std::move(result)); }, @@ -126,7 +119,7 @@ void WebFetchTool::execute( return; } if (url.scheme() != QLatin1String("http") && - url.scheme() != QLatin1String("https")) { + url.scheme() != QLatin1String("https")) { fail(QStringLiteral("URL must use http:// or https://")); return; } @@ -136,20 +129,18 @@ void WebFetchTool::execute( if (job->format != QLatin1String("html")) job->format = QStringLiteral("text"); - const int timeoutMs = qBound( - 1, - args[QStringLiteral("timeout")].toInt( - DefaultTimeoutSeconds), - MaxTimeoutSeconds) * - 1000; + const int timeoutMs = + qBound( + 1, + args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds), + MaxTimeoutSeconds) * + 1000; job->timer = new QTimer(this); job->timer->setSingleShot(true); - connect( - job->timer, &QTimer::timeout, this, [this, job]() { - if (job->reply) - job->reply->abort(); - }); + connect(job->timer, &QTimer::timeout, this, [this, job]() { + if (job->reply) job->reply->abort(); + }); job->timer->start(timeoutMs); QNetworkRequest request(url); @@ -159,13 +150,12 @@ void WebFetchTool::execute( job->format == QLatin1String("html") ? "text/html;q=1.0, application/xhtml+xml;q=0.9, */*;q=0.1" : "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, " - "*/*;q=0.1"); + "*/*;q=0.1"); request.setRawHeader("Accept-Language", "en-US,en;q=0.9"); job->reply = m_manager.get(request); connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() { - if (!job->reply) - return; + if (!job->reply) return; job->body += job->reply->readAll(); if (job->body.size() > MaxResponseBytes) { job->tooLarge = true; @@ -176,8 +166,7 @@ void WebFetchTool::execute( QNetworkReply* reply = job->reply; job->reply = nullptr; job->timer->stop(); - if (!reply) - return; + if (!reply) return; const QNetworkReply::NetworkError error = reply->error(); const QString errorString = reply->errorString(); @@ -187,13 +176,14 @@ void WebFetchTool::execute( const QByteArray contentType = reply->rawHeader("Content-Type").toLower(); const int status = - reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) - .toInt(); + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (job->tooLarge) { - completeJob(job, makeError( - QStringLiteral("Response too large (exceeds the 5 MB " - "limit"))); + completeJob( + job, + makeError(QStringLiteral( + "Response too large (exceeds the 5 MB " + "limit"))); return; } if (error != QNetworkReply::NoError) { @@ -207,25 +197,23 @@ void WebFetchTool::execute( completeJob( job, makeError( - QStringLiteral("Server returned status %1") - .arg(status))); + QStringLiteral("Server returned status %1").arg(status))); return; } - const QString mime = - QString::fromLatin1(contentType).section(QLatin1Char(';'), 0, 0) - .trimmed(); + const QString mime = QString::fromLatin1(contentType) + .section(QLatin1Char(';'), 0, 0) + .trimmed(); if (mime.startsWith(QLatin1String("image/"))) { completeJob( job, makeError( - QStringLiteral( - "Unsupported fetched image content type: %1") + QStringLiteral("Unsupported fetched image content type: %1") .arg(mime))); return; } - const bool textual = mime.isEmpty() || - mime.startsWith(QLatin1String("text/")) || + const bool textual = + mime.isEmpty() || mime.startsWith(QLatin1String("text/")) || mime == QLatin1String("application/json") || mime.endsWith(QLatin1String("+json")) || mime == QLatin1String("application/xml") || @@ -236,19 +224,18 @@ void WebFetchTool::execute( completeJob( job, makeError( - QStringLiteral( - "Unsupported fetched file content type: %1") + QStringLiteral("Unsupported fetched file content type: %1") .arg(mime))); return; } QString content = QString::fromUtf8(body); if (mime.contains(QLatin1String("text/html")) && - job->format == QLatin1String("text")) + job->format == QLatin1String("text")) content = extractTextFromHtml(content); if (content.size() > MaxOutputChars) content = content.left(MaxOutputChars) + - QStringLiteral("\n[... truncated ...]"); + QStringLiteral("\n[... truncated ...]"); completeJob(job, makeOutput(content)); }); } @@ -256,14 +243,12 @@ void WebFetchTool::execute( void WebFetchTool::cancel() { for (auto* job : m_jobs) { job->timer->stop(); - if (job->reply) - job->reply->abort(); + if (job->reply) job->reply->abort(); } } QString WebFetchTool::decodeEntities(const QString& text) { - if (!text.contains(QLatin1Char('&'))) - return text; + if (!text.contains(QLatin1Char('&'))) return text; QString out; out.reserve(text.size()); for (qsizetype i = 0; i < text.size(); ++i) { @@ -292,10 +277,11 @@ QString WebFetchTool::decodeEntities(const QString& text) { replacement = QLatin1Char(' '); else { bool ok = false; - const quint32 codePoint = entity.startsWith(QLatin1String("#x")) || - entity.startsWith(QLatin1String("#X")) - ? entity.mid(2).toUInt(&ok, 16) - : entity.toUInt(&ok); + const quint32 codePoint = + entity.startsWith(QLatin1String("#x")) || + entity.startsWith(QLatin1String("#X")) + ? entity.mid(2).toUInt(&ok, 16) + : entity.toUInt(&ok); if (ok && codePoint != 0) { const char32_t ucs4[2] = { static_cast(codePoint), @@ -326,12 +312,18 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) { QStringLiteral("style"), }; static const QSet kVoidTags = { - QStringLiteral("area"), QStringLiteral("base"), - QStringLiteral("br"), QStringLiteral("col"), - QStringLiteral("embed"), QStringLiteral("hr"), - QStringLiteral("img"), QStringLiteral("input"), - QStringLiteral("link"), QStringLiteral("meta"), - QStringLiteral("source"), QStringLiteral("track"), + QStringLiteral("area"), + QStringLiteral("base"), + QStringLiteral("br"), + QStringLiteral("col"), + QStringLiteral("embed"), + QStringLiteral("hr"), + QStringLiteral("img"), + QStringLiteral("input"), + QStringLiteral("link"), + QStringLiteral("meta"), + QStringLiteral("source"), + QStringLiteral("track"), QStringLiteral("wbr"), }; @@ -342,55 +334,43 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) { while (i < html.size()) { const qsizetype open = html.indexOf(QLatin1Char('<'), i); if (open < 0) { - if (skipDepth == 0) - text += html.mid(i); + if (skipDepth == 0) text += html.mid(i); break; } - if (skipDepth == 0) - text += html.mid(i, open - i); + if (skipDepth == 0) text += html.mid(i, open - i); const qsizetype close = html.indexOf(QLatin1Char('>'), open); - if (close < 0) - break; + if (close < 0) break; const QString tag = html.mid(open + 1, close - open - 1).trimmed().toLower(); i = close + 1; if (tag.startsWith(QLatin1Char('!')) || - tag.startsWith(QLatin1Char('?'))) + tag.startsWith(QLatin1Char('?'))) continue; QString name = tag; if (name.startsWith(QLatin1Char('/'))) { - if (skipDepth > 0) - --skipDepth; + if (skipDepth > 0) --skipDepth; continue; } qsizetype j = 0; - while (j < name.size() && - (name.at(j).isLetterOrNumber() || - name.at(j) == QLatin1Char(':') || - name.at(j) == QLatin1Char('-'))) + while (j < name.size() && (name.at(j).isLetterOrNumber() || + name.at(j) == QLatin1Char(':') || + name.at(j) == QLatin1Char('-'))) ++j; name = name.left(j); if (kRawTags.contains(name)) { - // Raw-text element: swallow everything up to its close tag. - const qsizetype rawEnd = - html.indexOf(QStringLiteral("'), rawEnd); - if (rawClose < 0) - break; + if (rawClose < 0) break; i = rawClose + 1; continue; } - if (kVoidTags.contains(name)) - continue; + if (kVoidTags.contains(name)) continue; if (skipDepth > 0) { - // Browsers implicitly close at ; malformed pages - // without a would otherwise swallow the whole page. if (name == QLatin1String("body")) { skipDepth = 0; continue; @@ -402,7 +382,6 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) { ++skipDepth; continue; } - // Normal tag: replace with a space so words do not merge. text += QLatin1Char(' '); } @@ -411,8 +390,7 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) { for (const QString& line : out.split(QLatin1Char('\n'))) { const QString flat = line.simplified(); if (flat.isEmpty()) { - if (!lines.isEmpty() && lines.last().isEmpty()) - continue; + if (!lines.isEmpty() && lines.last().isEmpty()) continue; lines.append(QString()); } else { lines.append(flat); diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp index 70046d1..b7bfd6b 100644 --- a/Plugins/ZShell/Llm/webfetchtool.hpp +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -13,11 +13,6 @@ class QTimer; namespace ZShell::llm { -// Fetches an http(s) URL and returns its content as plain text or raw -// HTML. Read-only. Mirrors opencode's webfetch tool, without markdown -// conversion and the permission prompt. Concurrent fetches are -// supported; results are always delivered on a later event loop -// iteration, never synchronously from execute(). class WebFetchTool : public LlmTool { Q_OBJECT @@ -25,8 +20,6 @@ class WebFetchTool : public LlmTool { static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int DefaultTimeoutSeconds = 30; static constexpr int MaxTimeoutSeconds = 120; - // Caps the characters handed to the model so a large page cannot - // blow out the context. static constexpr int MaxOutputChars = 64 * 1024; explicit WebFetchTool(QObject* parent = nullptr); @@ -40,8 +33,6 @@ class WebFetchTool : public LlmTool { std::function done) override; void cancel() override; - // Strips tags (skipping script/style/noscript/iframe/object/embed/ - // head) and decodes common entities. static QString extractTextFromHtml(const QString& html); static QString decodeEntities(const QString& text);