chore: format llm c files
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
JS/TS / lint (pull_request) Successful in 9s
JS/TS / fmt (pull_request) Successful in 16s
C++ / clang-tidy (pull_request) Failing after 43s
Python / static (pull_request) Failing after 57s
Rust / fmt (pull_request) Successful in 1m30s
Rust / build (pull_request) Successful in 2m5s
Rust / clippy (pull_request) Successful in 1m55s
Python / verify (pull_request) Successful in 2m23s

This commit is contained in:
2026-08-31 19:02:26 +02:00
parent dbb51ceadd
commit 52feb6006a
27 changed files with 741 additions and 1190 deletions
+13 -18
View File
@@ -10,9 +10,10 @@
namespace ZShell::llm { namespace ZShell::llm {
Chat::Chat(QObject* parent) Chat::Chat(QObject* parent)
: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) { : QObject(parent)
if (!config::Config::instance()) , m_store(new ChatStore(this))
new config::Config(); , m_client(new LlmClient(this)) {
if (!config::Config::instance()) new config::Config();
m_store->setLlmClient(m_client); m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
@@ -44,10 +45,8 @@ Chat::Chat(QObject* parent)
} }
Q_EMIT busyChanged(); Q_EMIT busyChanged();
}); });
connect( connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect(
m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect( connect(
m_client, m_client,
&LlmClient::availableModelsChanged, &LlmClient::availableModelsChanged,
@@ -88,7 +87,7 @@ Chat::Chat(QObject* parent)
this, this,
[this](ChatSession* session, const QString& title) { [this](ChatSession* session, const QString& title) {
qInfo() << "Chat: applying generated title" << session->id() qInfo() << "Chat: applying generated title" << session->id()
<< title << "(was" << session->title() << ")"; << title << "(was" << session->title() << ")";
session->setTitle(title); session->setTitle(title);
m_store->saveMeta(session); m_store->saveMeta(session);
}); });
@@ -97,8 +96,8 @@ Chat::Chat(QObject* parent)
&LlmClient::iconSuggested, &LlmClient::iconSuggested,
this, this,
[this](ChatSession* session, const QString& icon) { [this](ChatSession* session, const QString& icon) {
qInfo() << "Chat: applying generated icon" << session->id() qInfo() << "Chat: applying generated icon" << session->id() << icon
<< icon << "(was" << session->icon() << ")"; << "(was" << session->icon() << ")";
session->setIcon(icon); session->setIcon(icon);
m_store->saveMeta(session); m_store->saveMeta(session);
}); });
@@ -141,8 +140,7 @@ QString Chat::streamingChatId() const {
Chat* Chat::s_instance = nullptr; Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) { Chat* Chat::create(QQmlEngine*, QJSEngine*) {
if (!s_instance) if (!s_instance) s_instance = new Chat();
s_instance = new Chat();
return s_instance; return s_instance;
} }
@@ -151,8 +149,7 @@ void Chat::stop() {
} }
void Chat::dismissError() { void Chat::dismissError() {
if (m_lastError.isEmpty()) if (m_lastError.isEmpty()) return;
return;
m_lastError.clear(); m_lastError.clear();
Q_EMIT lastErrorChanged(); Q_EMIT lastErrorChanged();
} }
@@ -162,11 +159,9 @@ void Chat::refreshModels() {
} }
void Chat::selectModel(const QString& id) { void Chat::selectModel(const QString& id) {
if (id.isEmpty()) if (id.isEmpty()) return;
return;
m_client->setModel(id); m_client->setModel(id);
if (auto* config = config::Config::instance()) if (auto* config = config::Config::instance()) config->llm()->set_model(id);
config->llm()->set_model(id);
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+9 -3
View File
@@ -25,12 +25,18 @@ class Chat : public QObject {
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged) Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged)
Q_PROPERTY(QString model READ model NOTIFY modelChanged) 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(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(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) 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: public:
explicit Chat(QObject* parent = nullptr); explicit Chat(QObject* parent = nullptr);
+245 -298
View File
@@ -37,24 +37,16 @@ QString segmentTypeName(LlmSegment::Type type) {
} }
LlmSegment::Type segmentTypeFromName(const QString& name) { LlmSegment::Type segmentTypeFromName(const QString& name) {
if (name == QLatin1String("tool_call")) if (name == QLatin1String("tool_call")) return LlmSegment::Type::ToolCall;
return LlmSegment::Type::ToolCall; if (name == QLatin1String("content")) return LlmSegment::Type::Content;
if (name == QLatin1String("content"))
return LlmSegment::Type::Content;
return LlmSegment::Type::Reasoning; 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) { QString sqlText(const QString& value) {
if (value.isNull()) if (value.isNull()) return QStringLiteral("");
return QStringLiteral("");
return value; return value;
} }
// Plain data for one session's messages, fetched on a worker thread
// and turned into the QObject tree on the GUI thread. Messages are
// ordered as the model displays them (newest first).
struct SegmentRow { struct SegmentRow {
QString type; QString type;
QString text; QString text;
@@ -82,14 +74,13 @@ struct MessageRow {
} // namespace } // namespace
ChatStore::ChatStore(QObject* parent) ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) { : QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
openDb(); openDb();
load(); load();
} }
ChatStore::~ChatStore() { ChatStore::~ChatStore() {
if (m_connectionName.isEmpty()) if (m_connectionName.isEmpty()) return;
return;
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
db.close(); db.close();
QSqlDatabase::removeDatabase(m_connectionName); QSqlDatabase::removeDatabase(m_connectionName);
@@ -110,7 +101,7 @@ void ChatStore::openDb() {
db.setDatabaseName(m_dbPath); db.setDatabaseName(m_dbPath);
if (!db.open()) { if (!db.open()) {
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
<< db.lastError().text(); << db.lastError().text();
return; return;
} }
{ {
@@ -119,59 +110,55 @@ void ChatStore::openDb() {
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS sessions (\n"
"CREATE TABLE IF NOT EXISTS sessions (\n" " id TEXT PRIMARY KEY,\n"
" id TEXT PRIMARY KEY,\n" " title TEXT NOT NULL DEFAULT '',\n"
" title TEXT NOT NULL DEFAULT '',\n" " icon TEXT NOT NULL DEFAULT '',\n"
" icon TEXT NOT NULL DEFAULT '',\n" " created_at INTEGER NOT NULL,\n"
" created_at INTEGER NOT NULL,\n" " updated_at INTEGER NOT NULL,\n"
" updated_at INTEGER NOT NULL,\n" " pinned INTEGER NOT NULL DEFAULT 0\n"
" pinned INTEGER NOT NULL DEFAULT 0\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS messages (\n"
"CREATE TABLE IF NOT EXISTS messages (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " session_id TEXT NOT NULL REFERENCES sessions (id) "
" session_id TEXT NOT NULL REFERENCES sessions (id) " "ON DELETE CASCADE,\n"
"ON DELETE CASCADE,\n" " role TEXT NOT NULL,\n"
" role TEXT NOT NULL,\n" " timestamp INTEGER NOT NULL\n"
" timestamp INTEGER NOT NULL\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS generations (\n"
"CREATE TABLE IF NOT EXISTS generations (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " message_id INTEGER NOT NULL REFERENCES messages "
" message_id INTEGER NOT NULL REFERENCES messages " "(id) ON DELETE CASCADE,\n"
"(id) ON DELETE CASCADE,\n" " timestamp INTEGER NOT NULL,\n"
" timestamp INTEGER NOT NULL,\n" " is_active INTEGER NOT NULL DEFAULT 1\n"
" is_active INTEGER NOT NULL DEFAULT 1\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS segments (\n"
"CREATE TABLE IF NOT EXISTS segments (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " generation_id INTEGER NOT NULL REFERENCES generations "
" generation_id INTEGER NOT NULL REFERENCES generations " "(id) ON DELETE CASCADE,\n"
"(id) ON DELETE CASCADE,\n" " type TEXT NOT NULL,\n"
" type TEXT NOT NULL,\n" " text TEXT NOT NULL DEFAULT '',\n"
" text TEXT NOT NULL DEFAULT '',\n" " name TEXT NOT NULL DEFAULT '',\n"
" name TEXT NOT NULL DEFAULT '',\n" " tool_call_id TEXT NOT NULL DEFAULT '',\n"
" tool_call_id TEXT NOT NULL DEFAULT '',\n" " arguments TEXT NOT NULL DEFAULT '',\n"
" arguments TEXT NOT NULL DEFAULT '',\n" " result TEXT NOT NULL DEFAULT '',\n"
" result TEXT NOT NULL DEFAULT '',\n" " status INTEGER NOT NULL DEFAULT 0,\n"
" status INTEGER NOT NULL DEFAULT 0,\n" " elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" " timestamp INTEGER NOT NULL\n"
" timestamp INTEGER NOT NULL\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
@@ -200,8 +187,7 @@ QVariantList ChatStore::values() const {
} }
ChatSession* ChatStore::at(int index) const { ChatSession* ChatStore::at(int index) const {
if (index < 0 || index >= m_sessions.size()) if (index < 0 || index >= m_sessions.size()) return nullptr;
return nullptr;
return m_sessions.at(index); return m_sessions.at(index);
} }
@@ -218,7 +204,7 @@ ChatSession* ChatStore::insert(int index) {
query.bindValue(":updated_at", now); query.bindValue(":updated_at", now);
if (!query.exec()) if (!query.exec())
qWarning() << "ChatStore: failed to insert session" << id << ":" qWarning() << "ChatStore: failed to insert session" << id << ":"
<< query.lastError().text(); << query.lastError().text();
} }
auto* session = new ChatSession(id, this); auto* session = new ChatSession(id, this);
session->setMeta(QString(), now, now, 0); session->setMeta(QString(), now, now, 0);
@@ -238,8 +224,7 @@ void ChatStore::remove(ChatSession* chat) {
} }
void ChatStore::removeSession(ChatSession* session) { void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
return;
const QList<ChatSession*> before = m_sessions; const QList<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session); Q_EMIT sessionRemoved(session);
{ {
@@ -255,7 +240,7 @@ void ChatStore::removeSession(ChatSession* session) {
void ChatStore::move(int from, int to) { void ChatStore::move(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 || if (from < 0 || from >= m_sessions.size() || to < 0 ||
to >= m_sessions.size() || from == to) to >= m_sessions.size() || from == to)
return; return;
m_sessions.move(from, to); m_sessions.move(from, to);
Q_EMIT valuesChanged(); Q_EMIT valuesChanged();
@@ -269,8 +254,7 @@ void ChatStore::clear() {
ChatSession* ChatStore::sessionById(const QString& id) { ChatSession* ChatStore::sessionById(const QString& id) {
for (auto* session : m_sessions) for (auto* session : m_sessions)
if (session->id() == id) if (session->id() == id) return session;
return session;
return nullptr; return nullptr;
} }
@@ -279,32 +263,28 @@ void ChatStore::setLlmClient(LlmClient* client) {
} }
void ChatStore::persist(ChatSession* session) { void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
return;
if (!session->isLoaded()) { 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); m_pendingPersists.insert(session);
return; return;
} }
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
if (!saveSession(session)) if (!saveSession(session)) return;
return;
sortAndNotify(); sortAndNotify();
} }
void ChatStore::saveMeta(ChatSession* session) { void ChatStore::saveMeta(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
return;
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare("UPDATE sessions SET title = :title, icon = :icon " query.prepare(
"WHERE id = :id"); "UPDATE sessions SET title = :title, icon = :icon "
"WHERE id = :id");
query.bindValue(":title", sqlText(session->title())); query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon())); query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":id", session->id()); query.bindValue(":id", session->id());
if (!query.exec()) if (!query.exec())
qWarning() << "ChatStore: failed to save meta for" << session->id() qWarning() << "ChatStore: failed to save meta for" << session->id()
<< ":" << query.lastError().text(); << ":" << query.lastError().text();
} }
bool ChatStore::saveSession(ChatSession* session) { bool ChatStore::saveSession(ChatSession* session) {
@@ -312,7 +292,7 @@ bool ChatStore::saveSession(ChatSession* session) {
QSqlDatabase handle = db(); QSqlDatabase handle = db();
if (!handle.transaction()) { if (!handle.transaction()) {
qWarning() << "ChatStore: failed to begin transaction:" qWarning() << "ChatStore: failed to begin transaction:"
<< handle.lastError().text(); << handle.lastError().text();
return false; return false;
} }
bool ok = true; bool ok = true;
@@ -338,18 +318,18 @@ bool ChatStore::saveSession(ChatSession* session) {
"INSERT INTO messages (session_id, role, timestamp) " "INSERT INTO messages (session_id, role, timestamp) "
"VALUES (:id, :role, :timestamp)"); "VALUES (:id, :role, :timestamp)");
QSqlQuery generationInsert(handle); QSqlQuery generationInsert(handle);
ok = ok && generationInsert.prepare( ok = ok &&
"INSERT INTO generations (message_id, timestamp, is_active) " generationInsert.prepare(
"VALUES (:mid, :timestamp, :is_active)"); "INSERT INTO generations (message_id, timestamp, is_active) "
"VALUES (:mid, :timestamp, :is_active)");
QSqlQuery segmentInsert(handle); QSqlQuery segmentInsert(handle);
ok = ok && segmentInsert.prepare( ok = ok &&
"INSERT INTO segments (generation_id, type, text, name, " segmentInsert.prepare(
"tool_call_id, arguments, result, status, elapsed_ms, " "INSERT INTO segments (generation_id, type, text, name, "
"timestamp) VALUES (:gid, :type, :text, :name, " "tool_call_id, arguments, result, status, elapsed_ms, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, " "timestamp) VALUES (:gid, :type, :text, :name, "
":timestamp)"); ":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
// The model holds messages most recent first; the database keeps ":timestamp)");
// natural rowid order, so iterate from the oldest row up.
const auto* model = session->messagesModel(); const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) { for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(row); const auto* message = model->at(row);
@@ -363,8 +343,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (!messageInsert.exec()) { if (!messageInsert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "message insert failed:" << "message insert failed:"
<< messageInsert.lastError().text(); << messageInsert.lastError().text();
break; break;
} }
const int messageId = messageInsert.lastInsertId().toInt(); const int messageId = messageInsert.lastInsertId().toInt();
@@ -379,8 +359,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (!generationInsert.exec()) { if (!generationInsert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "generation insert failed:" << "generation insert failed:"
<< generationInsert.lastError().text(); << generationInsert.lastError().text();
break; break;
} }
const int generationId = const int generationId =
@@ -395,18 +375,17 @@ bool ChatStore::saveSession(ChatSession* session) {
":tool_call_id", sqlText(segment->toolCallId())); ":tool_call_id", sqlText(segment->toolCallId()));
segmentInsert.bindValue( segmentInsert.bindValue(
":arguments", sqlText(segment->arguments())); ":arguments", sqlText(segment->arguments()));
segmentInsert.bindValue(":result", sqlText(segment->result())); segmentInsert.bindValue(
":result", sqlText(segment->result()));
segmentInsert.bindValue( segmentInsert.bindValue(
":status", static_cast<int>(segment->status())); ":status", static_cast<int>(segment->status()));
segmentInsert.bindValue( segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
":elapsed_ms", segment->elapsedMs()); segmentInsert.bindValue(":timestamp", segment->timestamp());
segmentInsert.bindValue(
":timestamp", segment->timestamp());
if (!segmentInsert.exec()) { if (!segmentInsert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "segment insert failed:" << "segment insert failed:"
<< segmentInsert.lastError().text(); << segmentInsert.lastError().text();
break; break;
} }
} }
@@ -414,211 +393,183 @@ bool ChatStore::saveSession(ChatSession* session) {
} }
} }
if (!ok || !handle.commit()) { if (!ok || !handle.commit()) {
qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back"; qWarning() << "ChatStore: saveSession" << id
<< "commit failed, rolling back";
handle.rollback(); handle.rollback();
ok = false; ok = false;
qWarning() << "ChatStore: failed to save session" << session->id() << ":" qWarning() << "ChatStore: failed to save session" << session->id()
<< handle.lastError().text(); << ":" << handle.lastError().text();
} }
return ok; return ok;
} }
void ChatStore::loadMessagesInto(ChatSession* session) { void ChatStore::loadMessagesInto(ChatSession* session) {
if (!session) if (!session) return;
return;
const QString sessionId = session->id(); const QString sessionId = session->id();
const QString path = m_dbPath; const QString path = m_dbPath;
// SQL on a worker thread (its own connection; QSqlDatabase objects QThreadPool::globalInstance()->start([store = QPointer<ChatStore>(this),
// are thread-affine). Rows come back as plain data. session =
QThreadPool::globalInstance()->start( QPointer<ChatSession>(session),
[store = QPointer<ChatStore>(this), sessionId,
session = QPointer<ChatSession>(session), sessionId, path]() { path]() {
QList<MessageRow> rows; QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString(); const QString connName = QUuid::createUuid().toString();
{ {
QSqlDatabase db = QSqlDatabase::addDatabase( QSqlDatabase db =
QStringLiteral("QSQLITE"), connName); QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path); db.setDatabaseName(path);
if (db.open()) { if (db.open()) {
// Tolerate the GUI thread writing while we read. QSqlQuery busy(db);
QSqlQuery busy(db); busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); QSqlQuery query(db);
// Newest first so the model receives rows in query.prepare(
// display order. "SELECT id, role, timestamp FROM messages "
QSqlQuery query(db); "WHERE session_id = :id ORDER BY rowid DESC");
query.prepare( query.bindValue(":id", sessionId);
"SELECT id, role, timestamp FROM messages " if (!query.exec()) {
"WHERE session_id = :id ORDER BY rowid DESC"); qWarning() << "ChatStore: failed to load messages for"
query.bindValue(":id", sessionId); << sessionId << ":" << query.lastError().text();
if (!query.exec()) {
qWarning()
<< "ChatStore: failed to load messages for"
<< sessionId << ":"
<< query.lastError().text();
} else {
while (query.next()) {
const int messageId = query.value(0).toInt();
MessageRow message;
message.user =
query.value(1).toString() ==
QLatin1String("user");
message.timestamp = query.value(2).toLongLong();
QSqlQuery generationQuery(db);
generationQuery.prepare(
"SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid "
"ORDER BY rowid");
generationQuery.bindValue(":mid", messageId);
if (generationQuery.exec()) {
while (generationQuery.next()) {
GenerationRow generation;
generation.timestamp =
generationQuery.value(1).toLongLong();
generation.active =
generationQuery.value(2).toInt() != 0;
QSqlQuery segmentQuery(db);
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, "
"arguments, result, status, elapsed_ms, "
"timestamp FROM segments WHERE "
"generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue(
":gid",
generationQuery.value(0).toInt());
if (segmentQuery.exec()) {
while (segmentQuery.next()) {
SegmentRow segment;
segment.type =
segmentQuery.value(0).toString();
segment.text =
segmentQuery.value(1).toString();
segment.name =
segmentQuery.value(2).toString();
segment.toolCallId =
segmentQuery.value(3).toString();
segment.arguments =
segmentQuery.value(4).toString();
segment.result =
segmentQuery.value(5).toString();
segment.status =
segmentQuery.value(6).toInt();
segment.elapsedMs =
segmentQuery.value(7).toLongLong();
segment.timestamp =
segmentQuery.value(8).toLongLong();
generation.segments.append(segment);
}
} else {
qWarning()
<< "ChatStore: failed to load "
"segments for generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
}
message.generations.append(generation);
}
} else {
qWarning()
<< "ChatStore: failed to load generations "
"for message"
<< messageId << ":"
<< generationQuery.lastError().text();
}
rows.append(message);
}
}
db.close();
} else { } else {
qWarning() while (query.next()) {
<< "ChatStore: failed to open database for load:" const int messageId = query.value(0).toInt();
<< db.lastError().text(); MessageRow message;
} message.user = query.value(1).toString() ==
} QLatin1String("user");
// Remove only once every QSqlDatabase copy and query is gone; message.timestamp = query.value(2).toLongLong();
// while any reference is alive Qt refuses the removal and the QSqlQuery generationQuery(db);
// connection is left dangling in a broken state. generationQuery.prepare(
QSqlDatabase::removeDatabase(connName); "SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid "
// Build the object tree on the GUI thread. Deliver through "ORDER BY rowid");
// the app instance (never destroyed) and re-check the generationQuery.bindValue(":mid", messageId);
// pointers there: posting to `store` from the pool thread if (generationQuery.exec()) {
// would race with its destruction. while (generationQuery.next()) {
QMetaObject::invokeMethod( GenerationRow generation;
QCoreApplication::instance(), generation.timestamp =
[store, session, rows = std::move(rows)]() mutable { generationQuery.value(1).toLongLong();
ChatStore* st = store; generation.active =
ChatSession* s = session; generationQuery.value(2).toInt() != 0;
if (!st || !s) QSqlQuery segmentQuery(db);
return; segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, "
// Rows fetched from disk; newest first. "arguments, result, status, elapsed_ms, "
auto* model = s->model(); "timestamp FROM segments WHERE "
if (!model) "generation_id = :gid ORDER BY rowid");
return; segmentQuery.bindValue(
QList<ChatMessage*> messages; ":gid", generationQuery.value(0).toInt());
for (const MessageRow& row : rows) { if (segmentQuery.exec()) {
auto* message = model->createMessage( while (segmentQuery.next()) {
row.user ? ChatMessage::Role::User SegmentRow segment;
: ChatMessage::Role::Assistant, segment.type =
row.timestamp); segmentQuery.value(0).toString();
int activeIndex = 0; segment.text =
for (int i = 0; i < row.generations.size(); ++i) { segmentQuery.value(1).toString();
const GenerationRow& generationRow = segment.name =
row.generations.at(i); segmentQuery.value(2).toString();
auto* generation = segment.toolCallId =
message->addGeneration(generationRow.timestamp); segmentQuery.value(3).toString();
for (const SegmentRow& segmentRow : segment.arguments =
generationRow.segments) { segmentQuery.value(4).toString();
auto* segment = new LlmSegment( segment.result =
segmentTypeFromName(segmentRow.type), segmentQuery.value(5).toString();
segmentRow.timestamp, segment.status =
generation); segmentQuery.value(6).toInt();
segment->setText(segmentRow.text); segment.elapsedMs =
segment->setName(segmentRow.name); segmentQuery.value(7).toLongLong();
segment->setToolCallId(segmentRow.toolCallId); segment.timestamp =
segment->appendArguments(segmentRow.arguments); segmentQuery.value(8).toLongLong();
segment->setResult(segmentRow.result); generation.segments.append(segment);
segment->setStatus( }
static_cast<LlmSegment::Status>( } else {
segmentRow.status)); qWarning()
segment->restore(segmentRow.elapsedMs); << "ChatStore: failed to load "
generation->addSegment(segment); "segments for generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
}
message.generations.append(generation);
} }
if (generationRow.active) } else {
activeIndex = i; qWarning()
<< "ChatStore: failed to load generations "
"for message"
<< messageId << ":"
<< generationQuery.lastError().text();
} }
message->setActiveGeneration(activeIndex); rows.append(message);
messages.append(message);
} }
// Rows added live while the load was in flight are }
// newer than anything on disk; keep them in front. db.close();
if (model->rowCount() > 0) { } else {
QList<ChatMessage*> live = messages; qWarning() << "ChatStore: failed to open database for load:"
for (int r = 0; r < model->rowCount(); ++r) << db.lastError().text();
live.prepend(model->at(r)); }
messages = live; }
} QSqlDatabase::removeDatabase(connName);
if (!messages.isEmpty() || model->rowCount() > 0)
s->adoptMessages(messages);
// Mark loaded only once the model holds both the QMetaObject::invokeMethod(
// fetched history and the rows added live while the QCoreApplication::instance(),
// load ran, so a deferred startGeneration (triggered [store, session, rows = std::move(rows)]() mutable {
// by loaded()) builds its context from the complete ChatStore* st = store;
// conversation. ChatSession* s = session;
s->markLoaded(); if (!st || !s) return;
if (s->takeClearPending()) { auto* model = s->model();
// Cleared while the load was in flight; drop if (!model) return;
// everything now that the model is populated. QList<ChatMessage*> messages;
s->clear(); for (const MessageRow& row : rows) {
} else if (st->m_pendingPersists.remove(s)) { auto* message = model->createMessage(
st->persist(s); row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
row.timestamp);
int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow =
row.generations.at(i);
auto* generation =
message->addGeneration(generationRow.timestamp);
for (const SegmentRow& segmentRow :
generationRow.segments) {
auto* segment = new LlmSegment(
segmentTypeFromName(segmentRow.type),
segmentRow.timestamp,
generation);
segment->setText(segmentRow.text);
segment->setName(segmentRow.name);
segment->setToolCallId(segmentRow.toolCallId);
segment->appendArguments(segmentRow.arguments);
segment->setResult(segmentRow.result);
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentRow.status));
segment->restore(segmentRow.elapsedMs);
generation->addSegment(segment);
}
if (generationRow.active) activeIndex = i;
} }
}, message->setActiveGeneration(activeIndex);
Qt::QueuedConnection); messages.append(message);
}); }
if (model->rowCount() > 0) {
QList<ChatMessage*> live = messages;
for (int r = 0; r < model->rowCount(); ++r)
live.prepend(model->at(r));
messages = live;
}
if (!messages.isEmpty() || model->rowCount() > 0)
s->adoptMessages(messages);
s->markLoaded();
if (s->takeClearPending()) {
s->clear();
} else if (st->m_pendingPersists.remove(s)) {
st->persist(s);
}
},
Qt::QueuedConnection);
});
} }
void ChatStore::load() { void ChatStore::load() {
@@ -647,22 +598,18 @@ void ChatStore::sortAndNotify() {
m_sessions.begin(), m_sessions.begin(),
m_sessions.end(), m_sessions.end(),
[](const ChatSession* a, const ChatSession* b) { [](const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned()) if (a->pinned() != b->pinned()) return a->pinned() > b->pinned();
return a->pinned() > b->pinned();
return a->updatedAtMs() > b->updatedAtMs(); return a->updatedAtMs() > b->updatedAtMs();
}); });
notify(before); notify(before);
} }
void ChatStore::notify(const QList<ChatSession*>& before) { void ChatStore::notify(const QList<ChatSession*>& before) {
if (before.size() != m_sessions.size()) if (before.size() != m_sessions.size()) Q_EMIT countChanged();
Q_EMIT countChanged();
bool same = before.size() == m_sessions.size(); bool same = before.size() == m_sessions.size();
for (int i = 0; same && i < m_sessions.size(); ++i) for (int i = 0; same && i < m_sessions.size(); ++i)
if (before.at(i) != m_sessions.at(i)) if (before.at(i) != m_sessions.at(i)) same = false;
same = false; if (!same) Q_EMIT valuesChanged();
if (!same)
Q_EMIT valuesChanged();
} }
} // namespace ZShell::llm } // namespace ZShell::llm
-5
View File
@@ -39,9 +39,6 @@ class ChatStore : public QObject {
void persist(ChatSession* session); void persist(ChatSession* session);
void saveMeta(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); void loadMessagesInto(ChatSession* session);
Q_SIGNALS: Q_SIGNALS:
@@ -61,8 +58,6 @@ class ChatStore : public QObject {
LlmClient* m_llmClient = nullptr; LlmClient* m_llmClient = nullptr;
QString m_connectionName; QString m_connectionName;
QString m_dbPath; QString m_dbPath;
// Sessions whose persist() ran before their messages finished
// loading; persisted once the load lands.
QSet<ChatSession*> m_pendingPersists; QSet<ChatSession*> m_pendingPersists;
[[nodiscard]] QSqlDatabase db() const; [[nodiscard]] QSqlDatabase db() const;
-1
View File
@@ -213,7 +213,6 @@ QVariantList CodeHighlighter::lookupSpans(
QMutexLocker locker(&m_cacheMutex); QMutexLocker locker(&m_cacheMutex);
const auto it = m_spanCache.constFind(key); const auto it = m_spanCache.constFind(key);
if (it == m_spanCache.constEnd() || it->code != code) return {}; 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); const qsizetype pos = m_spanCacheOrder.indexOf(key);
if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1); if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1);
return it->spans; return it->spans;
+16 -51
View File
@@ -15,38 +15,6 @@ class QJSEngine;
namespace ZShell::llm { 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 { class CodeHighlighter : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -54,17 +22,16 @@ class CodeHighlighter : public QObject {
public: public:
Q_INVOKABLE void highlight( 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*); static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
struct Grammar { struct Grammar {
// Library candidates in priority order (system package, then
// Neovim copies); libs[i] pairs with symbols[i].
std::vector<std::string> libs; std::vector<std::string> libs;
std::vector<std::string> symbols; std::vector<std::string> symbols;
// Candidate query sources in priority order; first that
// compiles against the loaded grammar wins.
std::vector<const char*> queries; std::vector<const char*> queries;
}; };
@@ -73,29 +40,27 @@ class CodeHighlighter : public QObject {
bool bad = false; // permanent failure, do not retry bool bad = false; // permanent failure, do not retry
void* lib = nullptr; void* lib = nullptr;
const void* lang = nullptr; // const TSLanguage* const void* lang = nullptr; // const TSLanguage*
void* query = nullptr; // TSQuery* void* query = nullptr; // TSQuery*
}; };
[[nodiscard]] static const QHash<QString, QString>& aliases(); [[nodiscard]] static const QHash<QString, QString>& 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 uint8_t roleFor(const char* name, uint32_t length);
[[nodiscard]] static const char* roleName(uint8_t role); [[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 resolveId(const QString& language);
[[nodiscard]] static QString cacheKey(const QString& id, const QString& code); [[nodiscard]] static QString cacheKey(
// The parsing work; runs on worker threads, so the per-language const QString& id, const QString& code);
// state must be initialized under m_stateMutex and is shared as an [[nodiscard]] QVariantList doHighlight(
// immutable object afterwards. const QString& code, const QString& language) const;
[[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const; [[nodiscard]] QVariantList lookupSpans(
// Exact-match span cache. lookupSpans() runs on the GUI thread, const QString& code, const QString& language) const;
// storeSpans() on worker threads; both take m_cacheMutex. void storeSpans(
[[nodiscard]] QVariantList lookupSpans(const QString& code, const QString& language) const; const QString& code,
void storeSpans(const QString& code, const QString& language, const QString& language,
const QVariantList& spans) const; const QVariantList& spans) const;
struct SpanCacheEntry { struct SpanCacheEntry {
QString code; // re-compared on lookup; a hash collision can QString code; // re-compared on lookup; a hash collision can
// never deliver the wrong spans // never deliver the wrong spans
QVariantList spans; QVariantList spans;
}; };
+44 -76
View File
@@ -5,22 +5,19 @@
namespace ZShell::llm { namespace ZShell::llm {
ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent)
: QObject(parent), m_timestamp(timestamp) { : QObject(parent), m_timestamp(timestamp) {
m_timer.setParent(this); m_timer.setParent(this);
m_timer.setInterval(500); m_timer.setInterval(500);
m_timer.setTimerType(Qt::CoarseTimer); m_timer.setTimerType(Qt::CoarseTimer);
connect(&m_timer, &QTimer::timeout, this, [this]() { connect(&m_timer, &QTimer::timeout, this, [this]() {
bool anyRunning = false; bool anyRunning = false;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (!segment->running()) if (!segment->running()) continue;
continue;
anyRunning = true; anyRunning = true;
segment->elapsedMsChanged(); segment->elapsedMsChanged();
} }
if (anyRunning) if (anyRunning) Q_EMIT elapsedMsChanged();
Q_EMIT elapsedMsChanged(); if (!anyRunning && !m_streaming) m_timer.stop();
if (!anyRunning && !m_streaming)
m_timer.stop();
}); });
} }
@@ -28,7 +25,7 @@ QString ChatGeneration::content() const {
QStringList parts; QStringList parts;
for (const auto* segment : m_segments) { for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content || if (segment->type() != LlmSegment::Type::Content ||
segment->text().isEmpty()) segment->text().isEmpty())
continue; continue;
parts.append(segment->text()); parts.append(segment->text());
} }
@@ -39,7 +36,7 @@ QString ChatGeneration::reasoning() const {
QStringList parts; QStringList parts;
for (const auto* segment : m_segments) { for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Reasoning || if (segment->type() != LlmSegment::Type::Reasoning ||
segment->text().isEmpty()) segment->text().isEmpty())
continue; continue;
parts.append(segment->text()); parts.append(segment->text());
} }
@@ -47,10 +44,8 @@ QString ChatGeneration::reasoning() const {
} }
bool ChatGeneration::reasoningActive() const { bool ChatGeneration::reasoningActive() const {
if (!m_streaming) if (!m_streaming) return false;
return false; if (!content().isEmpty()) return false;
if (!content().isEmpty())
return false;
return !hasRunningTool(); return !hasRunningTool();
} }
@@ -81,44 +76,35 @@ qint64 ChatGeneration::toolsElapsedMs() const {
int ChatGeneration::toolCallCount() const { int ChatGeneration::toolCallCount() const {
int count = 0; int count = 0;
for (const auto* segment : m_segments) for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall) if (segment->type() == LlmSegment::Type::ToolCall) ++count;
++count;
return count; return count;
} }
bool ChatGeneration::hasRunningTool() const { bool ChatGeneration::hasRunningTool() const {
for (const auto* segment : m_segments) for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall && if (segment->type() == LlmSegment::Type::ToolCall && segment->running())
segment->running())
return true; return true;
return false; return false;
} }
void ChatGeneration::updateReasoningActive() { void ChatGeneration::updateReasoningActive() {
const bool active = reasoningActive(); const bool active = reasoningActive();
if (m_reasoningActive == active) if (m_reasoningActive == active) return;
return;
m_reasoningActive = active; m_reasoningActive = active;
Q_EMIT reasoningActiveChanged(); Q_EMIT reasoningActiveChanged();
} }
void ChatGeneration::setContent(const QString& value) { 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; LlmSegment* first = nullptr;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content) if (segment->type() != LlmSegment::Type::Content) continue;
continue;
if (!first) if (!first)
first = segment; first = segment;
else else
segment->setText(QString()); segment->setText(QString());
} }
if (!first) { if (!first) {
// Do not materialize an empty content segment (e.g. the assistant if (value.isEmpty()) return;
// placeholder created before the stream starts).
if (value.isEmpty())
return;
first = new LlmSegment( first = new LlmSegment(
LlmSegment::Type::Content, LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(), QDateTime::currentMSecsSinceEpoch(),
@@ -129,35 +115,30 @@ void ChatGeneration::setContent(const QString& value) {
} }
void ChatGeneration::appendContent(const QString& piece) { void ChatGeneration::appendContent(const QString& piece) {
if (piece.isEmpty()) if (piece.isEmpty()) return;
return;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Reasoning && if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running()) segment->running())
segment->close(); segment->close();
} }
openContentSegment()->appendText(piece); openContentSegment()->appendText(piece);
} }
void ChatGeneration::appendReasoning(const QString& piece) { void ChatGeneration::appendReasoning(const QString& piece) {
if (piece.isEmpty()) if (piece.isEmpty()) return;
return;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Content && if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->running())
segment->close(); segment->close();
} }
openReasoningSegment()->appendText(piece); openReasoningSegment()->appendText(piece);
} }
void ChatGeneration::setStreaming(bool value) { void ChatGeneration::setStreaming(bool value) {
if (m_streaming == value) if (m_streaming == value) return;
return;
m_streaming = value; m_streaming = value;
Q_EMIT streamingChanged(); Q_EMIT streamingChanged();
if (value) { if (value) {
if (!m_timer.isActive()) if (!m_timer.isActive()) m_timer.start();
m_timer.start();
} else { } else {
closeOpenSegments(); closeOpenSegments();
m_timer.stop(); m_timer.stop();
@@ -168,13 +149,10 @@ void ChatGeneration::setStreaming(bool value) {
LlmSegment* ChatGeneration::openContentSegment() { LlmSegment* ChatGeneration::openContentSegment() {
for (auto* segment : m_segments) for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Content && if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->running())
return segment; return segment;
auto* segment = new LlmSegment( auto* segment = new LlmSegment(
LlmSegment::Type::Content, LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this);
QDateTime::currentMSecsSinceEpoch(),
this);
segment->begin(); segment->begin();
addSegment(segment); addSegment(segment);
return segment; return segment;
@@ -183,12 +161,10 @@ LlmSegment* ChatGeneration::openContentSegment() {
LlmSegment* ChatGeneration::openReasoningSegment() { LlmSegment* ChatGeneration::openReasoningSegment() {
for (auto* segment : m_segments) for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Reasoning && if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running()) segment->running())
return segment; return segment;
auto* segment = new LlmSegment( auto* segment = new LlmSegment(
LlmSegment::Type::Reasoning, LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this);
QDateTime::currentMSecsSinceEpoch(),
this);
segment->begin(); segment->begin();
addSegment(segment); addSegment(segment);
return segment; return segment;
@@ -198,9 +174,7 @@ LlmSegment* ChatGeneration::beginToolCall(
const QString& name, const QString& toolCallId) { const QString& name, const QString& toolCallId) {
closeOpenSegments(); closeOpenSegments();
auto* segment = new LlmSegment( auto* segment = new LlmSegment(
LlmSegment::Type::ToolCall, LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
QDateTime::currentMSecsSinceEpoch(),
this);
segment->setName(name); segment->setName(name);
segment->setToolCallId(toolCallId); segment->setToolCallId(toolCallId);
segment->setStatus(LlmSegment::Status::Running); segment->setStatus(LlmSegment::Status::Running);
@@ -211,39 +185,33 @@ LlmSegment* ChatGeneration::beginToolCall(
} }
void ChatGeneration::addSegment(LlmSegment* segment) { void ChatGeneration::addSegment(LlmSegment* segment) {
if (!segment || m_segments.contains(segment)) if (!segment || m_segments.contains(segment)) return;
return;
segment->setParent(this); segment->setParent(this);
connect( connect(segment, &LlmSegment::textChanged, this, [this, segment]() {
segment, &LlmSegment::textChanged, this, [this, segment]() { if (segment->type() == LlmSegment::Type::Reasoning)
if (segment->type() == LlmSegment::Type::Reasoning) Q_EMIT reasoningChanged();
Q_EMIT reasoningChanged(); else if (segment->type() == LlmSegment::Type::Content)
else if (segment->type() == LlmSegment::Type::Content) Q_EMIT contentChanged();
Q_EMIT contentChanged(); updateReasoningActive();
updateReasoningActive(); });
}); connect(segment, &LlmSegment::statusChanged, this, [this]() {
connect( Q_EMIT toolStateChanged();
segment, &LlmSegment::statusChanged, this, [this]() { });
Q_EMIT toolStateChanged(); connect(segment, &LlmSegment::resultChanged, this, [this]() {
}); Q_EMIT toolStateChanged();
connect( });
segment, &LlmSegment::resultChanged, this, [this]() { connect(segment, &LlmSegment::runningChanged, this, [this]() {
Q_EMIT toolStateChanged(); Q_EMIT elapsedMsChanged();
}); Q_EMIT toolStateChanged();
connect( updateReasoningActive();
segment, &LlmSegment::runningChanged, this, [this]() { });
Q_EMIT elapsedMsChanged();
Q_EMIT toolStateChanged();
updateReasoningActive();
});
m_segments.append(segment); m_segments.append(segment);
Q_EMIT segmentsChanged(); Q_EMIT segmentsChanged();
} }
void ChatGeneration::closeOpenSegments() { void ChatGeneration::closeOpenSegments() {
for (auto* segment : m_segments) for (auto* segment : m_segments)
if (segment->running()) if (segment->running()) segment->close();
segment->close();
updateReasoningActive(); updateReasoningActive();
} }
+11 -22
View File
@@ -10,39 +10,35 @@
namespace ZShell::llm { 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 { class ChatGeneration : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
QML_UNCREATABLE("Chat generations are managed by ChatMessage") 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(QString reasoning READ reasoning NOTIFY reasoningChanged)
Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(
Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
elapsedMsChanged)
Q_PROPERTY(
qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) 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(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY( Q_PROPERTY(
QList<ZShell::llm::LlmSegment*> segments READ segments QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
NOTIFY segmentsChanged) segmentsChanged)
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
public: public:
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
// All content bursts, joined (the model's full answer).
[[nodiscard]] QString content() const; [[nodiscard]] QString content() const;
// Every reasoning burst, joined.
[[nodiscard]] QString reasoning() const; [[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]] bool reasoningActive() const;
[[nodiscard]] qint64 reasoningElapsedMs() const; [[nodiscard]] qint64 reasoningElapsedMs() const;
[[nodiscard]] qint64 contentElapsedMs() const; [[nodiscard]] qint64 contentElapsedMs() const;
@@ -58,18 +54,11 @@ class ChatGeneration : public QObject {
void appendReasoning(const QString& piece); void appendReasoning(const QString& piece);
void setStreaming(bool value); 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(); [[nodiscard]] LlmSegment* openContentSegment();
// The in-flight reasoning segment, or a fresh one.
[[nodiscard]] LlmSegment* openReasoningSegment(); [[nodiscard]] LlmSegment* openReasoningSegment();
// Creates and appends a running tool-call segment.
[[nodiscard]] LlmSegment* beginToolCall( [[nodiscard]] LlmSegment* beginToolCall(
const QString& name, const QString& toolCallId); const QString& name, const QString& toolCallId);
// Appends a segment created by the persistence layer.
void addSegment(LlmSegment* segment); void addSegment(LlmSegment* segment);
// Stops the clocks of every in-flight segment.
void closeOpenSegments(); void closeOpenSegments();
Q_SIGNALS: Q_SIGNALS:
+151 -241
View File
@@ -22,8 +22,7 @@ QString LlmClient::completionsPath(
QString base = endpoint.trimmed(); QString base = endpoint.trimmed();
while (base.endsWith('/')) while (base.endsWith('/'))
base.chop(1); base.chop(1);
if (!base.endsWith("/v1")) if (!base.endsWith("/v1")) base += "/v1";
base += "/v1";
return base + subpath; return base + subpath;
} }
@@ -37,28 +36,24 @@ QString LlmClient::serverErrorMessage(
if (errorValue.isObject()) { if (errorValue.isObject()) {
const QString message = const QString message =
errorValue.toObject()["message"].toString(); errorValue.toObject()["message"].toString();
if (!message.isEmpty()) if (!message.isEmpty()) return message;
return message;
} else if (!errorValue.toString().isEmpty()) { } else if (!errorValue.toString().isEmpty()) {
return errorValue.toString(); return errorValue.toString();
} }
} }
} }
return fallback.isEmpty() return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed")
? QStringLiteral("Request to LLM server failed") : fallback;
: fallback;
} }
void LlmClient::setBusy(bool value) { void LlmClient::setBusy(bool value) {
if (m_busy == value) if (m_busy == value) return;
return;
m_busy = value; m_busy = value;
Q_EMIT busyChanged(); Q_EMIT busyChanged();
} }
void LlmClient::setStreamingChatId(const QString& id) { void LlmClient::setStreamingChatId(const QString& id) {
if (m_streamingChatId == id) if (m_streamingChatId == id) return;
return;
m_streamingChatId = id; m_streamingChatId = id;
Q_EMIT streamingChatIdChanged(); Q_EMIT streamingChatIdChanged();
} }
@@ -74,39 +69,32 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) {
} }
LlmClient::~LlmClient() { LlmClient::~LlmClient() {
if (m_reply) if (m_reply) m_reply->abort();
m_reply->abort();
m_tools->cancelAll(); m_tools->cancelAll();
endStream(); endStream();
} }
void LlmClient::setEndpoint(const QString& value) { void LlmClient::setEndpoint(const QString& value) {
if (m_endpoint == value) if (m_endpoint == value) return;
return;
m_endpoint = value; m_endpoint = value;
Q_EMIT endpointChanged(); Q_EMIT endpointChanged();
probeContextSize(); probeContextSize();
if (m_model.isEmpty()) if (m_model.isEmpty()) refreshModels();
refreshModels();
} }
void LlmClient::setModel(const QString& value) { void LlmClient::setModel(const QString& value) {
if (m_model == value) if (m_model == value) return;
return;
m_model = value; m_model = value;
Q_EMIT modelChanged(); Q_EMIT modelChanged();
if (m_model.isEmpty()) if (m_model.isEmpty()) refreshModels();
refreshModels();
} }
void LlmClient::setTemperature(double value) { void LlmClient::setTemperature(double value) {
m_temperature = value; m_temperature = value;
} }
void LlmClient::startGeneration( void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
ChatSession* session, ChatGeneration* target) { if (m_busy || !session || !target) return;
if (m_busy || !session || !target)
return;
m_active = session; m_active = session;
m_streaming = target; m_streaming = target;
m_streaming->setStreaming(true); m_streaming->setStreaming(true);
@@ -124,8 +112,9 @@ void LlmClient::startGeneration(
const int targetRow = const int targetRow =
model->rowOf(qobject_cast<ChatMessage*>(target->parent())); model->rowOf(qobject_cast<ChatMessage*>(target->parent()));
if (targetRow < 0) { if (targetRow < 0) {
fail(QStringLiteral("Internal error: generation target is not in the " fail(QStringLiteral(
"session")); "Internal error: generation target is not in the "
"session"));
return; return;
} }
@@ -141,8 +130,7 @@ void LlmClient::startGeneration(
} }
void LlmClient::sendRound() { void LlmClient::sendRound() {
if (!m_active || !m_streaming) if (!m_active || !m_streaming) return;
return;
m_finishReason.clear(); m_finishReason.clear();
m_callBuilders.clear(); m_callBuilders.clear();
m_callResults.clear(); m_callResults.clear();
@@ -162,13 +150,12 @@ void LlmClient::sendRound() {
const int targetRow = const int targetRow =
model->rowOf(qobject_cast<ChatMessage*>(m_streaming->parent())); model->rowOf(qobject_cast<ChatMessage*>(m_streaming->parent()));
if (targetRow < 0) { if (targetRow < 0) {
fail(QStringLiteral("Internal error: generation target is not in the " fail(QStringLiteral(
"session")); "Internal error: generation target is not in the "
"session"));
return; 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); QJsonArray messages = buildContextMessages(m_active, targetRow);
for (const QJsonValue& value : m_transcript) for (const QJsonValue& value : m_transcript)
messages.append(value); messages.append(value);
@@ -180,11 +167,9 @@ void LlmClient::sendRound() {
body[QStringLiteral("messages")] = messages; body[QStringLiteral("messages")] = messages;
body[QStringLiteral("stream")] = true; body[QStringLiteral("stream")] = true;
body[QStringLiteral("temperature")] = m_temperature; body[QStringLiteral("temperature")] = m_temperature;
if (!m_model.isEmpty()) if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model;
body[QStringLiteral("model")] = m_model;
const QJsonArray toolSpecs = m_tools->specifications(); const QJsonArray toolSpecs = m_tools->specifications();
if (!toolSpecs.isEmpty()) if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs;
body[QStringLiteral("tools")] = toolSpecs;
QNetworkRequest request(url); QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
@@ -193,26 +178,22 @@ void LlmClient::sendRound() {
m_reply = m_manager.post(request, QJsonDocument(body).toJson()); m_reply = m_manager.post(request, QJsonDocument(body).toJson());
connect(m_reply, &QNetworkReply::readyRead, this, [this]() { connect(m_reply, &QNetworkReply::readyRead, this, [this]() {
if (m_reply) if (m_reply) m_buffer.append(m_reply->readAll());
m_buffer.append(m_reply->readAll());
drainBuffer(); drainBuffer();
}); });
connect(m_reply, &QNetworkReply::finished, this, [this]() { connect(m_reply, &QNetworkReply::finished, this, [this]() {
QNetworkReply* reply = m_reply; QNetworkReply* reply = m_reply;
if (!reply) if (!reply) return;
return;
m_reply = nullptr; m_reply = nullptr;
const QNetworkReply::NetworkError error = reply->error(); const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString(); const QString errorString = reply->errorString();
// Data not already consumed by readyRead is only reachable here.
const QByteArray responseBody = reply->readAll(); const QByteArray responseBody = reply->readAll();
m_buffer.append(responseBody); m_buffer.append(responseBody);
reply->deleteLater(); reply->deleteLater();
drainBuffer(); drainBuffer();
if (!m_streaming) if (!m_streaming) return;
return;
if (error == QNetworkReply::NoError) if (error == QNetworkReply::NoError)
roundFinished(); roundFinished();
@@ -230,8 +211,7 @@ QJsonArray LlmClient::buildContextMessages(
for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) {
const auto* message = model->at(row); const auto* message = model->at(row);
const auto* generation = message->activeGeneration(); const auto* generation = message->activeGeneration();
if (!generation) if (!generation) continue;
continue;
if (message->role() == ChatMessage::Role::User) { if (message->role() == ChatMessage::Role::User) {
QJsonObject user; QJsonObject user;
@@ -241,8 +221,6 @@ QJsonArray LlmClient::buildContextMessages(
continue; continue;
} }
// Assistant message: replay its tool calls (and their results)
// so the model keeps the full history of the turn.
QList<const LlmSegment*> toolSegments; QList<const LlmSegment*> toolSegments;
for (const auto* segment : generation->segments()) for (const auto* segment : generation->segments())
if (segment->type() == LlmSegment::Type::ToolCall) if (segment->type() == LlmSegment::Type::ToolCall)
@@ -278,8 +256,7 @@ QJsonArray LlmClient::buildContextMessages(
for (const auto* segment : toolSegments) { for (const auto* segment : toolSegments) {
QJsonObject toolMessage; QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = toolMessage[QStringLiteral("tool_call_id")] = segment->toolCallId();
segment->toolCallId();
toolMessage[QStringLiteral("content")] = segment->result(); toolMessage[QStringLiteral("content")] = segment->result();
messages.append(toolMessage); messages.append(toolMessage);
} }
@@ -288,51 +265,37 @@ QJsonArray LlmClient::buildContextMessages(
} }
void LlmClient::applyToolCallDelta(const QJsonObject& call) { void LlmClient::applyToolCallDelta(const QJsonObject& call) {
if (!m_streaming) if (!m_streaming) return;
return;
const int index = call[QStringLiteral("index")].toInt(-1); const int index = call[QStringLiteral("index")].toInt(-1);
if (index < 0) if (index < 0) return;
return;
while (m_callBuilders.size() <= index) while (m_callBuilders.size() <= index)
m_callBuilders.append(ToolCallBuilder{}); m_callBuilders.append(ToolCallBuilder{});
ToolCallBuilder& builder = m_callBuilders[index]; ToolCallBuilder& builder = m_callBuilders[index];
builder.seen = true; builder.seen = true;
const QString id = call[QStringLiteral("id")].toString(); const QString id = call[QStringLiteral("id")].toString();
if (!id.isEmpty()) if (!id.isEmpty()) builder.id = id;
builder.id = id;
const QJsonObject function = call[QStringLiteral("function")].toObject(); const QJsonObject function = call[QStringLiteral("function")].toObject();
const QString name = function[QStringLiteral("name")].toString(); const QString name = function[QStringLiteral("name")].toString();
if (!name.isEmpty()) if (!name.isEmpty()) builder.name = name;
builder.name = name; const QString arguments = function[QStringLiteral("arguments")].toString();
const QString arguments = if (!arguments.isEmpty()) builder.arguments += arguments;
function[QStringLiteral("arguments")].toString();
if (!arguments.isEmpty())
builder.arguments += arguments;
if (!builder.segment) { 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(); m_streaming->closeOpenSegments();
builder.segment = builder.segment = m_streaming->beginToolCall(builder.name, builder.id);
m_streaming->beginToolCall(builder.name, builder.id);
} }
builder.segment->setName(builder.name); builder.segment->setName(builder.name);
builder.segment->setToolCallId(builder.id); builder.segment->setToolCallId(builder.id);
if (!arguments.isEmpty()) if (!arguments.isEmpty()) builder.segment->appendArguments(arguments);
builder.segment->appendArguments(arguments);
} }
void LlmClient::roundFinished() { void LlmClient::roundFinished() {
// [DONE] and the reply's finished signal both funnel here; only the if (m_roundDone || !m_streaming) return;
// first may act.
if (m_roundDone || !m_streaming)
return;
m_roundDone = true; m_roundDone = true;
bool hasCalls = false; bool hasCalls = false;
for (const auto& builder : m_callBuilders) for (const auto& builder : m_callBuilders)
if (builder.seen) if (builder.seen) hasCalls = true;
hasCalls = true;
if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) { if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) {
finishTurn(); finishTurn();
return; return;
@@ -345,8 +308,6 @@ void LlmClient::roundFinished() {
m_streaming->closeOpenSegments(); 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 content = m_streaming->content();
const QString reasoning = m_streaming->reasoning(); const QString reasoning = m_streaming->reasoning();
QJsonObject assistant; QJsonObject assistant;
@@ -362,8 +323,7 @@ void LlmClient::roundFinished() {
} }
QJsonArray calls; QJsonArray calls;
for (const auto& builder : m_callBuilders) { for (const auto& builder : m_callBuilders) {
if (!builder.seen) if (!builder.seen) continue;
continue;
QJsonObject function; QJsonObject function;
function[QStringLiteral("name")] = builder.name; function[QStringLiteral("name")] = builder.name;
function[QStringLiteral("arguments")] = builder.arguments; function[QStringLiteral("arguments")] = builder.arguments;
@@ -387,112 +347,95 @@ void LlmClient::executeAllCalls() {
for (int i = 0; i < m_callBuilders.size(); ++i) { for (int i = 0; i < m_callBuilders.size(); ++i) {
const auto& call = m_callBuilders.at(i); const auto& call = m_callBuilders.at(i);
if (!call.seen) if (!call.seen) continue;
continue;
LlmTool* tool = m_tools->tool(call.name); LlmTool* tool = m_tools->tool(call.name);
QJsonObject args; QJsonObject args;
QString errorText; QString errorText;
if (!tool) { if (!tool) {
errorText = QStringLiteral("Error: unknown tool '%1'") errorText =
.arg(call.name); QStringLiteral("Error: unknown tool '%1'").arg(call.name);
} else if (!call.arguments.isEmpty()) { } else if (!call.arguments.isEmpty()) {
const QJsonDocument doc = const QJsonDocument doc =
QJsonDocument::fromJson(call.arguments.toUtf8()); QJsonDocument::fromJson(call.arguments.toUtf8());
if (!doc.isObject()) { if (!doc.isObject()) {
errorText = errorText = QStringLiteral(
QStringLiteral("Error: tool arguments are not valid " "Error: tool arguments are not valid "
"JSON: %1") "JSON: %1")
.arg(call.arguments); .arg(call.arguments);
} else { } else {
args = doc.object(); args = doc.object();
} }
} }
if (!errorText.isEmpty()) { if (!errorText.isEmpty()) {
m_callResults[i] = { errorText, false }; m_callResults[i] = {errorText, false};
if (LlmSegment* segment = call.segment) if (LlmSegment* segment = call.segment)
segment->finishTool(errorText, false); segment->finishTool(errorText, false);
continue; continue;
} }
++m_pendingCalls; ++m_pendingCalls;
tool->execute( tool->execute(args, [this, i, call](const QJsonObject& result) {
args, if (!m_streaming) return;
[this, i, call](const QJsonObject& result) { const bool success = result.contains(QStringLiteral("output"));
if (!m_streaming) const QString content =
return; success ? result[QStringLiteral("output")].toString()
const bool success = : QStringLiteral("Error: ") +
result.contains(QStringLiteral("output")); result[QStringLiteral("error")].toString();
const QString content = success m_callResults[i] = {content, success};
? result[QStringLiteral("output")].toString() if (LlmSegment* segment = call.segment)
: QStringLiteral("Error: ") + segment->finishTool(content, success);
result[QStringLiteral("error")].toString(); if (--m_pendingCalls == 0) flushCallResults();
m_callResults[i] = { content, success }; });
if (LlmSegment* segment = call.segment)
segment->finishTool(content, success);
if (--m_pendingCalls == 0)
flushCallResults();
});
} }
if (m_pendingCalls == 0) if (m_pendingCalls == 0) flushCallResults();
flushCallResults();
} }
void LlmClient::flushCallResults() { void LlmClient::flushCallResults() {
if (!m_toolPhase) if (!m_toolPhase) return;
return;
m_toolPhase = false; m_toolPhase = false;
if (!m_streaming) if (!m_streaming) return;
return;
for (int i = 0; i < m_callBuilders.size(); ++i) { for (int i = 0; i < m_callBuilders.size(); ++i) {
const auto& call = m_callBuilders.at(i); const auto& call = m_callBuilders.at(i);
if (!call.seen) if (!call.seen) continue;
continue;
QJsonObject toolMessage; QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = call.id; toolMessage[QStringLiteral("tool_call_id")] = call.id;
toolMessage[QStringLiteral("content")] = toolMessage[QStringLiteral("content")] = m_callResults.at(i).content;
m_callResults.at(i).content;
m_transcript.append(toolMessage); m_transcript.append(toolMessage);
} }
sendRound(); sendRound();
} }
void LlmClient::stop() { void LlmClient::stop() {
if (!m_busy) if (!m_busy) return;
return;
if (m_toolPhase) { if (m_toolPhase) {
m_tools->cancelAll(); m_tools->cancelAll();
if (m_streaming) { if (m_streaming) {
for (auto* segment : m_streaming->segments()) { for (auto* segment : m_streaming->segments()) {
if (segment->type() == LlmSegment::Type::ToolCall && if (segment->type() == LlmSegment::Type::ToolCall &&
segment->running()) segment->running())
segment->finishTool( segment->finishTool(QStringLiteral("Cancelled"), false);
QStringLiteral("Cancelled"), false);
} }
} }
finishTurn(); finishTurn();
return; return;
} }
if (m_reply) if (m_reply) m_reply->abort();
m_reply->abort();
} }
void LlmClient::endStream() { void LlmClient::endStream() {
if (!m_streaming) if (!m_streaming) return;
return;
auto* generation = m_streaming; auto* generation = m_streaming;
auto* session = m_active; auto* session = m_active;
m_streaming = nullptr; m_streaming = nullptr;
m_active = nullptr; m_active = nullptr;
generation->setStreaming(false); generation->setStreaming(false);
if (generation->content().isEmpty() && if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
generation->reasoning().isEmpty() && generation->toolCallCount() == 0) {
generation->toolCallCount() == 0) {
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) { if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
if (message->generationCount() <= 1) { if (message->generationCount() <= 1) {
if (session) if (session) session->removeMessage(message);
session->removeMessage(message);
} else { } else {
message->removeGeneration(generation); message->removeGeneration(generation);
} }
@@ -503,16 +446,14 @@ void LlmClient::endStream() {
} }
void LlmClient::finishTurn() { void LlmClient::finishTurn() {
if (!m_streaming) if (!m_streaming) return;
return;
ChatSession* session = m_active; ChatSession* session = m_active;
endStream(); endStream();
if (session && m_pendingClear == session) { if (session && m_pendingClear == session) {
session->clearMessages(); session->clearMessages();
m_pendingClear.clear(); m_pendingClear.clear();
} }
if (session) if (session) session->persist();
session->persist();
} }
void LlmClient::clearOnFinish(ChatSession* session) { void LlmClient::clearOnFinish(ChatSession* session) {
@@ -520,8 +461,7 @@ void LlmClient::clearOnFinish(ChatSession* session) {
} }
void LlmClient::sessionRemoved(ChatSession* session) { void LlmClient::sessionRemoved(ChatSession* session) {
if (m_pendingClear == session) if (m_pendingClear == session) m_pendingClear.clear();
m_pendingClear.clear();
if (m_active == session) { if (m_active == session) {
stop(); stop();
endStream(); endStream();
@@ -537,8 +477,7 @@ void LlmClient::fail(const QString& message) {
void LlmClient::drainBuffer() { void LlmClient::drainBuffer() {
while (true) { while (true) {
const qsizetype newline = m_buffer.indexOf('\n'); const qsizetype newline = m_buffer.indexOf('\n');
if (newline < 0) if (newline < 0) break;
break;
const QByteArray line = m_buffer.left(newline).trimmed(); const QByteArray line = m_buffer.left(newline).trimmed();
m_buffer.remove(0, newline + 1); m_buffer.remove(0, newline + 1);
handleLine(line); handleLine(line);
@@ -546,8 +485,7 @@ void LlmClient::drainBuffer() {
} }
void LlmClient::handleLine(const QByteArray& line) { void LlmClient::handleLine(const QByteArray& line) {
if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) return;
return;
const QByteArray data = line.mid(5).trimmed(); const QByteArray data = line.mid(5).trimmed();
if (data == "[DONE]") { if (data == "[DONE]") {
@@ -556,42 +494,35 @@ void LlmClient::handleLine(const QByteArray& line) {
} }
const QJsonDocument doc = QJsonDocument::fromJson(data); const QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) if (!doc.isObject()) return;
return;
const QJsonObject obj = doc.object(); const QJsonObject obj = doc.object();
updateTokenUsage(obj); updateTokenUsage(obj);
if (obj.contains("error")) { if (obj.contains("error")) {
const QJsonObject error = obj["error"].toObject(); const QJsonObject error = obj["error"].toObject();
const QString message = error["message"].toString(); const QString message = error["message"].toString();
fail(message.isEmpty() fail(
? QStringLiteral("LLM server returned an error") message.isEmpty() ? QStringLiteral("LLM server returned an error")
: message); : message);
return; return;
} }
for (const QJsonValue& choiceValue : obj["choices"].toArray()) { for (const QJsonValue& choiceValue : obj["choices"].toArray()) {
if (!m_streaming) if (!m_streaming) continue;
continue;
const QJsonObject choice = choiceValue.toObject(); const QJsonObject choice = choiceValue.toObject();
const QJsonObject delta = choice["delta"].toObject(); const QJsonObject delta = choice["delta"].toObject();
const QString finishReason = const QString finishReason =
choice[QStringLiteral("finish_reason")].toString(); choice[QStringLiteral("finish_reason")].toString();
if (!finishReason.isEmpty()) if (!finishReason.isEmpty()) m_finishReason = finishReason;
m_finishReason = finishReason;
m_streaming->appendContent(delta["content"].toString()); m_streaming->appendContent(delta["content"].toString());
QString reasoning = QString reasoning = delta["reasoning_content"].toString();
delta["reasoning_content"].toString(); if (reasoning.isEmpty()) reasoning = delta["reasoning"].toString();
if (reasoning.isEmpty())
reasoning = delta["reasoning"].toString();
m_streaming->appendReasoning(reasoning); m_streaming->appendReasoning(reasoning);
for (const QJsonValue& callValue : for (const QJsonValue& callValue : delta["tool_calls"].toArray()) {
delta["tool_calls"].toArray()) { if (!m_streaming) break;
if (!m_streaming)
break;
applyToolCallDelta(callValue.toObject()); applyToolCallDelta(callValue.toObject());
} }
} }
@@ -600,8 +531,7 @@ void LlmClient::handleLine(const QByteArray& line) {
void LlmClient::refreshModels() { void LlmClient::refreshModels() {
const QUrl url = const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
if (!url.isValid() || url.host().isEmpty()) if (!url.isValid() || url.host().isEmpty()) return;
return;
auto* reply = m_manager.get(QNetworkRequest(url)); auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
@@ -625,8 +555,7 @@ void LlmClient::refreshModels() {
models.append(id); models.append(id);
} }
} }
if (models.isEmpty()) if (models.isEmpty()) return;
return;
m_availableModels = models; m_availableModels = models;
Q_EMIT availableModelsChanged(); Q_EMIT availableModelsChanged();
@@ -639,66 +568,55 @@ void LlmClient::refreshModels() {
} }
void LlmClient::setContextSize(int size) { void LlmClient::setContextSize(int size) {
if (size <= 0 || m_contextSize == size) if (size <= 0 || m_contextSize == size) return;
return;
m_contextSize = size; m_contextSize = size;
Q_EMIT contextSizeChanged(); Q_EMIT contextSizeChanged();
} }
void LlmClient::probeContextSize() { void LlmClient::probeContextSize() {
// llama.cpp-specific endpoint; other servers fall back to 4096.
QString base = m_endpoint.trimmed(); QString base = m_endpoint.trimmed();
while (base.endsWith('/')) while (base.endsWith('/'))
base.chop(1); base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props"); const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty()) if (!url.isValid() || url.host().isEmpty()) return;
return;
auto* reply = m_manager.get(QNetworkRequest(url)); auto* reply = m_manager.get(QNetworkRequest(url));
connect( connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply, const QNetworkReply::NetworkError error = reply->error();
&QNetworkReply::finished, const QByteArray data = reply->readAll();
this, reply->deleteLater();
[this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QByteArray data = reply->readAll();
reply->deleteLater();
int size = 0; int size = 0;
if (error == QNetworkReply::NoError) { if (error == QNetworkReply::NoError) {
const QJsonDocument doc = QJsonDocument::fromJson(data); const QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isArray()) { if (doc.isArray()) {
for (const auto& value : doc.array()) { for (const auto& value : doc.array()) {
const QJsonObject slot = value.toObject(); const QJsonObject slot = value.toObject();
if (slot.contains("n_ctx")) { if (slot.contains("n_ctx")) {
size = slot["n_ctx"].toInt(0); size = slot["n_ctx"].toInt(0);
if (size > 0) if (size > 0) break;
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) { void LlmClient::updateTokenUsage(const QJsonObject& data) {
if (!m_active || m_contextSize <= 0) if (!m_active || m_contextSize <= 0) return;
return;
const QJsonObject usage = data["usage"].toObject(); const QJsonObject usage = data["usage"].toObject();
if (usage.isEmpty()) if (usage.isEmpty()) return;
return; const double used = usage.value("prompt_tokens").toDouble() +
const double used = usage.value("completion_tokens").toDouble();
usage.value("prompt_tokens").toDouble() + if (used > 0) m_active->setLastTokenCount(static_cast<int>(used));
usage.value("completion_tokens").toDouble();
if (used > 0)
m_active->setLastTokenCount(static_cast<int>(used));
} }
void LlmClient::shortRequest( void LlmClient::shortRequest(
@@ -708,11 +626,10 @@ void LlmClient::shortRequest(
std::function<void(QString result)> onResult) { std::function<void(QString result)> onResult) {
const QUrl url = const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions"));
if (!url.isValid() || url.host().isEmpty()) if (!url.isValid() || url.host().isEmpty()) return;
return;
qInfo() << "LlmClient:" << tag << "request POST" << url.toString() qInfo() << "LlmClient:" << tag << "request POST" << url.toString()
<< "model=" << m_model; << "model=" << m_model;
QNetworkRequest request(url); QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
@@ -729,8 +646,7 @@ void LlmClient::shortRequest(
messages.append(user); messages.append(user);
QJsonObject body; QJsonObject body;
if (!m_model.isEmpty()) if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model;
body[QStringLiteral("model")] = m_model;
body[QStringLiteral("stream")] = false; body[QStringLiteral("stream")] = false;
body[QStringLiteral("temperature")] = 0.3; body[QStringLiteral("temperature")] = 0.3;
body[QStringLiteral("max_tokens")] = 128; body[QStringLiteral("max_tokens")] = 128;
@@ -749,25 +665,25 @@ void LlmClient::shortRequest(
reply->deleteLater(); reply->deleteLater();
qInfo() << "LlmClient:" << tag << "request finished" qInfo() << "LlmClient:" << tag << "request finished"
<< "error=" << reply->error() << reply->errorString() << "error=" << reply->error() << reply->errorString()
<< "http=" << reply->attribute( << "http="
QNetworkRequest::HttpStatusCodeAttribute) << reply
.toInt() ->attribute(QNetworkRequest::HttpStatusCodeAttribute)
<< "response=" .toInt()
<< QString::fromUtf8(data.left(400)).simplified(); << "response="
<< QString::fromUtf8(data.left(400)).simplified();
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError) return;
return;
const QJsonDocument doc = QJsonDocument::fromJson(data); const QJsonDocument doc = QJsonDocument::fromJson(data);
const QJsonArray choices = const QJsonArray choices =
doc.object()[QStringLiteral("choices")].toArray(); doc.object()[QStringLiteral("choices")].toArray();
if (choices.isEmpty()) if (choices.isEmpty()) return;
return; const QString result = choices.at(0)
const QString result = .toObject()[QStringLiteral("message")]
choices.at(0).toObject()[QStringLiteral("message")].toObject() .toObject()[QStringLiteral("content")]
[QStringLiteral("content")].toString() .toString()
.trimmed(); .trimmed();
qInfo() << "LlmClient:" << tag << "raw result" << result; qInfo() << "LlmClient:" << tag << "raw result" << result;
onResult(result); onResult(result);
}); });
@@ -788,24 +704,21 @@ void LlmClient::requestTitle(ChatSession* session, const QString& userText) {
} }
const auto isQuote = [](QChar c) { const auto isQuote = [](QChar c) {
return c == QLatin1Char('"') || c == QLatin1Char('\'') || return c == QLatin1Char('"') || c == QLatin1Char('\'') ||
c == QChar(u'\u201C') || c == QChar(u'\u201D') || c == QChar(u'\u201C') || c == QChar(u'\u201D') ||
c == QChar(u'\u2018') || c == QChar(u'\u2019'); c == QChar(u'\u2018') || c == QChar(u'\u2019');
}; };
while (title.size() >= 2 && isQuote(title.at(0)) && 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(); title = title.mid(1, title.size() - 2).simplified();
while (!title.isEmpty() && while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) ||
(title.endsWith(QLatin1Char('.')) || title.endsWith(QLatin1Char('!')) ||
title.endsWith(QLatin1Char('!')) || title.endsWith(QLatin1Char('?'))))
title.endsWith(QLatin1Char('?'))))
title.chop(1); title.chop(1);
if (title.size() < 2) { if (title.size() < 2) {
qWarning() << "LlmClient: title rejected (too short)" qWarning() << "LlmClient: title rejected (too short)" << title;
<< title;
return; return;
} }
if (title.size() > 48) if (title.size() > 48) title = title.left(47) + QStringLiteral("");
title = title.left(47) + QStringLiteral("");
qInfo() << "LlmClient: suggesting title" << title; qInfo() << "LlmClient: suggesting title" << title;
Q_EMIT titleSuggested(session, 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) { void LlmClient::requestIcon(ChatSession* session, const QString& userText) {
const QStringList icons = { const QStringList icons = {
"chat", "lightbulb", "code", "chat", "lightbulb", "code", "description",
"description", "article", "school", "article", "school", "work", "build",
"work", "build", "science", "science", "palette", "music_note", "sports_esports",
"palette", "music_note", "sports_esports", "takeout_dining", "flight", "photo_camera", "psychology_alt",
"takeout_dining", "flight", "photo_camera", "favorite", "savings", "gamepad", "auto_awesome",
"psychology_alt", "favorite", "savings",
"gamepad", "auto_awesome",
}; };
const QString prompt = const QString prompt =
QStringLiteral( QStringLiteral(
@@ -831,8 +742,7 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) {
QStringLiteral("icon"), QStringLiteral("icon"),
prompt, prompt,
userText, userText,
[this, session = QPointer<ChatSession>(session), icons]( [this, session = QPointer<ChatSession>(session), icons](QString name) {
QString name) {
if (!session) { if (!session) {
qWarning() << "LlmClient: icon request: session gone"; qWarning() << "LlmClient: icon request: session gone";
return; return;
@@ -842,12 +752,12 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) {
return c == QLatin1Char('"') || c == QLatin1Char('\''); return c == QLatin1Char('"') || c == QLatin1Char('\'');
}; };
while (name.size() >= 2 && isQuote(name.at(0)) && 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 = name.mid(1, name.size() - 2).simplified();
name.replace(QLatin1Char(' '), QLatin1Char('_')); name.replace(QLatin1Char(' '), QLatin1Char('_'));
if (!icons.contains(name)) { if (!icons.contains(name)) {
qWarning() << "LlmClient: icon not in list, using default" qWarning() << "LlmClient: icon not in list, using default"
<< name; << name;
name = QStringLiteral("chat"); name = QStringLiteral("chat");
} }
qInfo() << "LlmClient: suggesting icon" << name; qInfo() << "LlmClient: suggesting icon" << name;
+2 -22
View File
@@ -23,9 +23,6 @@ class ChatSession;
class LlmSegment; class LlmSegment;
class LlmTool; 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 { class LlmClient : public QObject {
Q_OBJECT Q_OBJECT
@@ -53,17 +50,10 @@ class LlmClient : public QObject {
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; } [[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 startGeneration(ChatSession* session, ChatGeneration* target);
void stop(); void stop();
void endStream(); void endStream();
// Clears the session's conversation once the current stream ends.
void clearOnFinish(ChatSession* session); void clearOnFinish(ChatSession* session);
// A session is about to be destroyed; drop any state pointing at it.
void sessionRemoved(ChatSession* session); void sessionRemoved(ChatSession* session);
void refreshModels(); void refreshModels();
@@ -91,23 +81,13 @@ class LlmClient : public QObject {
bool seen = false; bool seen = false;
}; };
// Sends one streaming round: context + transcript so far.
void sendRound(); void sendRound();
// The session context for a round, oldest first, ending just before
// `stopBeforeRow` (the message of the generation being streamed).
QJsonArray buildContextMessages( QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow) const; ChatSession* session, int stopBeforeRow) const;
void applyToolCallDelta(const QJsonObject& call); 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(); void roundFinished();
// Dispatches every call of the round; tools run concurrently.
void executeAllCalls(); void executeAllCalls();
// All results in: appends the tool messages (in call order) and
// sends the next round.
void flushCallResults(); void flushCallResults();
// Ends the current turn gracefully and persists the session.
void finishTurn(); void finishTurn();
void fail(const QString& message); void fail(const QString& message);
void handleLine(const QByteArray& line); void handleLine(const QByteArray& line);
@@ -120,7 +100,8 @@ class LlmClient : public QObject {
const QString& systemPrompt, const QString& systemPrompt,
const QString& userText, const QString& userText,
std::function<void(QString result)> onResult); std::function<void(QString result)> onResult);
static QString completionsPath(const QString& endpoint, const QString& subpath); static QString completionsPath(
const QString& endpoint, const QString& subpath);
static QString serverErrorMessage( static QString serverErrorMessage(
const QByteArray& body, const QString& fallback); const QByteArray& body, const QString& fallback);
@@ -139,7 +120,6 @@ class LlmClient : public QObject {
double m_temperature = 0.7; double m_temperature = 0.7;
int m_contextSize = 0; int m_contextSize = 0;
// State of the multi-round tool loop of the current turn.
QJsonArray m_transcript; QJsonArray m_transcript;
QList<ToolCallBuilder> m_callBuilders; QList<ToolCallBuilder> m_callBuilders;
struct ToolCallResult { struct ToolCallResult {
+3 -6
View File
@@ -5,9 +5,6 @@
namespace ZShell::llm { 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 { class LlmMarkdown : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -16,9 +13,9 @@ class LlmMarkdown : public QObject {
public: public:
enum class Type : int { enum class Type : int {
Text = 0, // Paragraph, list, quote, table; "text" is markdown source Text = 0, // Paragraph, list, quote, table; "text" is markdown source
Heading, // "level" + "text" (markdown source of the content) Heading, // "level" + "text" (markdown source of the content)
Code, // "language" + "code" Code, // "language" + "code"
Math // "latex" (display math, without the $$ delimiters) Math // "latex" (display math, without the $$ delimiters)
}; };
Q_ENUM(Type) Q_ENUM(Type)
+21 -40
View File
@@ -13,15 +13,12 @@ namespace ZShell::llm {
QVariantList MarkdownParser::parse(const QString& source) { QVariantList MarkdownParser::parse(const QString& source) {
QVariantList blocks; QVariantList blocks;
if (source.trimmed().isEmpty()) if (source.trimmed().isEmpty()) return blocks;
return blocks;
const QStringList lines = source.split('\n'); const QStringList lines = source.split('\n');
// cmark-gfm line/column numbers are 1-based and inclusive.
auto sliceSource = [&](int startLine, int endLine) -> QString { auto sliceSource = [&](int startLine, int endLine) -> QString {
if (startLine < 1 || endLine < startLine) if (startLine < 1 || endLine < startLine) return QString();
return QString();
const int from = startLine; const int from = startLine;
const int to = qMin(endLine, static_cast<int>(lines.size())); const int to = qMin(endLine, static_cast<int>(lines.size()));
return lines.mid(from - 1, to - from + 1).join('\n').trimmed(); 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(); const QByteArray utf8 = source.toUtf8();
cmark_node* doc = cmark_parse_document( cmark_node* doc = cmark_parse_document(
utf8.constData(), static_cast<size_t>(utf8.size()), utf8.constData(),
static_cast<size_t>(utf8.size()),
CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS); CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS);
if (!doc) if (!doc) return blocks;
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( const QRegularExpression mathRe(
QStringLiteral("\\$\\$(.+?)\\$\\$"), QStringLiteral("\\$\\$(.+?)\\$\\$"),
QRegularExpression::DotMatchesEverythingOption); QRegularExpression::DotMatchesEverythingOption);
@@ -45,29 +38,22 @@ QVariantList MarkdownParser::parse(const QString& source) {
auto makeBlock = [&](LlmMarkdown::Type type) { auto makeBlock = [&](LlmMarkdown::Type type) {
QVariantMap block; QVariantMap block;
block.insert("type", static_cast<int>(type)); block.insert("type", static_cast<int>(type));
// Stable per-position identity ("index:type") for the QML block.insert(
// ScriptModel: blocks that survive a re-parse keep their id, so "id",
// their delegates are updated in place instead of recreated QString::number(static_cast<int>(blocks.size())) +
// (which would drop code highlights mid-stream). The type is QLatin1Char(':') + QString::number(static_cast<int>(type)));
// part of the id so a block that changes type is rebuilt.
block.insert("id",
QString::number(static_cast<int>(blocks.size()))
+ QLatin1Char(':')
+ QString::number(static_cast<int>(type)));
return block; return block;
}; };
auto appendText = [&](const QString& text) { auto appendText = [&](const QString& text) {
if (text.trimmed().isEmpty()) if (text.trimmed().isEmpty()) return;
return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Text); QVariantMap block = makeBlock(LlmMarkdown::Type::Text);
block.insert("text", text); block.insert("text", text);
blocks.append(block); blocks.append(block);
}; };
auto appendMath = [&](const QString& latex) { auto appendMath = [&](const QString& latex) {
if (latex.trimmed().isEmpty()) if (latex.trimmed().isEmpty()) return;
return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Math); QVariantMap block = makeBlock(LlmMarkdown::Type::Math);
block.insert("latex", latex); block.insert("latex", latex);
blocks.append(block); blocks.append(block);
@@ -81,8 +67,7 @@ QVariantList MarkdownParser::parse(const QString& source) {
}; };
auto appendHeading = [&](int level, const QString& text) { auto appendHeading = [&](int level, const QString& text) {
if (text.trimmed().isEmpty()) if (text.trimmed().isEmpty()) return;
return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Heading); QVariantMap block = makeBlock(LlmMarkdown::Type::Heading);
block.insert("level", level); block.insert("level", level);
block.insert("text", text); block.insert("text", text);
@@ -90,7 +75,7 @@ QVariantList MarkdownParser::parse(const QString& source) {
}; };
for (cmark_node* node = cmark_node_first_child(doc); node; 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 cmark_node_type type = cmark_node_get_type(node);
const int startLine = cmark_node_get_start_line(node); const int startLine = cmark_node_get_start_line(node);
const int endLine = cmark_node_get_end_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) { if (type == CMARK_NODE_CODE_BLOCK) {
const char* literal = cmark_node_get_literal(node); const char* literal = cmark_node_get_literal(node);
QString code = literal ? QString::fromUtf8(literal) : QString(); 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; QString language;
if (const char* info = cmark_node_get_fence_info(node); info) 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); appendCode(language, code);
continue; continue;
} }
if (type == CMARK_NODE_HEADING) { 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); const char* content = cmark_node_get_string_content(node);
appendHeading( appendHeading(
cmark_node_get_heading_level(node), cmark_node_get_heading_level(node),
@@ -125,11 +109,11 @@ QVariantList MarkdownParser::parse(const QString& source) {
int cursor = 0; int cursor = 0;
bool anyMath = false; bool anyMath = false;
for (auto it = mathRe.globalMatch(text, cursor); it.hasNext(); for (auto it = mathRe.globalMatch(text, cursor); it.hasNext();
it = mathRe.globalMatch(text, cursor)) { it = mathRe.globalMatch(text, cursor)) {
const QRegularExpressionMatch m = it.next(); const QRegularExpressionMatch m = it.next();
anyMath = true; anyMath = true;
appendText( appendText(text.mid(
text.mid(cursor, static_cast<int>(m.capturedStart() - cursor))); cursor, static_cast<int>(m.capturedStart() - cursor)));
appendMath(m.captured(1).trimmed()); appendMath(m.captured(1).trimmed());
cursor = static_cast<int>(m.capturedEnd()); cursor = static_cast<int>(m.capturedEnd());
} }
@@ -140,9 +124,6 @@ QVariantList MarkdownParser::parse(const QString& source) {
continue; continue;
} }
// Lists, block quotes, tables, horizontal rules, custom blocks:
// hand the raw markdown source to QML (rendered via
// Text.MarkdownText).
appendText(sliceSource(startLine, endLine)); appendText(sliceSource(startLine, endLine));
} }
-12
View File
@@ -5,18 +5,6 @@
namespace ZShell::llm { 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 { class MarkdownParser {
public: public:
[[nodiscard]] static QVariantList parse(const QString& source); [[nodiscard]] static QVariantList parse(const QString& source);
+99 -123
View File
@@ -16,15 +16,10 @@
namespace ZShell::llm { namespace ZShell::llm {
namespace { namespace {
// A few px of breathing room around the equation.
constexpr int kRenderMargin = 2; constexpr int kRenderMargin = 2;
constexpr unsigned int kResolutionDpi = 96; 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; 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 { struct LatinModern {
bool roman = false; bool roman = false;
bool math = false; bool math = false;
@@ -33,37 +28,44 @@ struct LatinModern {
const LatinModern& loadLatinModern() { const LatinModern& loadLatinModern() {
static const LatinModern fonts = [] { static const LatinModern fonts = [] {
LatinModern result; LatinModern result;
// addApplicationFont(QByteArray) fails in this environment, so const auto add = [](const unsigned char* data,
// the embedded bytes are staged to a per-process temp file and size_t size,
// registered through the (stable) file-based API. const QString& fileName,
const auto add = [](const unsigned char* data, size_t size, const QString& family) {
const QString& fileName, const QString& family) {
const QString path = QDir::tempPath() + QLatin1Char('/') + fileName; const QString path = QDir::tempPath() + QLatin1Char('/') + fileName;
{ {
QFile f(path); QFile f(path);
if (!f.open(QIODevice::WriteOnly) || if (!f.open(QIODevice::WriteOnly) ||
f.write(reinterpret_cast<const char*>(data), f.write(
static_cast<qint64>(size)) != static_cast<qint64>(size)) reinterpret_cast<const char*>(data),
static_cast<qint64>(size)) != static_cast<qint64>(size))
return false; return false;
} }
const int key = QFontDatabase::addApplicationFont(path); const int key = QFontDatabase::addApplicationFont(path);
if (key < 0) if (key < 0) return false;
return false;
return QFontDatabase::applicationFontFamilies(key).contains(family); return QFontDatabase::applicationFontFamilies(key).contains(family);
}; };
result.roman = result.roman = add(lmfont::lmroman10_regular,
add(lmfont::lmroman10_regular, sizeof(lmfont::lmroman10_regular), sizeof(lmfont::lmroman10_regular),
QStringLiteral("lmroman10-regular.otf"), QStringLiteral("LMRoman10")) QStringLiteral("lmroman10-regular.otf"),
&& add(lmfont::lmroman10_italic, sizeof(lmfont::lmroman10_italic), QStringLiteral("LMRoman10")) &&
QStringLiteral("lmroman10-italic.otf"), QStringLiteral("LMRoman10")) add(lmfont::lmroman10_italic,
&& add(lmfont::lmroman10_bold, sizeof(lmfont::lmroman10_bold), sizeof(lmfont::lmroman10_italic),
QStringLiteral("lmroman10-bold.otf"), QStringLiteral("LMRoman10")) QStringLiteral("lmroman10-italic.otf"),
&& add(lmfont::lmroman10_bolditalic, sizeof(lmfont::lmroman10_bolditalic), QStringLiteral("LMRoman10")) &&
QStringLiteral("lmroman10-bolditalic.otf"), add(lmfont::lmroman10_bold,
QStringLiteral("LMRoman10")); sizeof(lmfont::lmroman10_bold),
result.math = add( QStringLiteral("lmroman10-bold.otf"),
lmfont::latinmodern_math, sizeof(lmfont::latinmodern_math), QStringLiteral("LMRoman10")) &&
QStringLiteral("latinmodern-math.otf"), QStringLiteral("Latin Modern Math")); 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 result;
}(); }();
return fonts; return fonts;
@@ -72,22 +74,17 @@ const LatinModern& loadLatinModern() {
} // namespace } // namespace
LlmMathText::LlmMathText(QObject* parent) LlmMathText::LlmMathText(QObject* parent)
// No parent: the renderer is used from pool threads, and a parented : QObject(parent)
// QObject would taint children it creates there. , m_renderer(
: QObject(parent), m_renderer(std::make_shared<JKQTMathText>( std::make_shared<JKQTMathText>(nullptr, /* useFontsForGUI */ true)) {
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.
const LatinModern& fonts = loadLatinModern(); const LatinModern& fonts = loadLatinModern();
if (fonts.roman) if (fonts.roman)
m_renderer->setFontRomanAndMath(QStringLiteral("LMRoman10"), m_renderer->setFontRomanAndMath(
JKQTMathTextFontEncoding::MTFEUnicode); QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode);
if (fonts.math) { if (fonts.math) {
// Same pattern as JKQTMathText's useXITS(): the OpenType math m_renderer->setFontMathRoman(
// font supplies the math alphabet and operators from its MATH QStringLiteral("Latin Modern Math"),
// table. JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFontMathRoman(QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFallbackFontSymbols( m_renderer->setFallbackFontSymbols(
QStringLiteral("Latin Modern Math"), QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode); JKQTMathTextFontEncoding::MTFEUnicode);
@@ -95,38 +92,31 @@ LlmMathText::LlmMathText(QObject* parent)
} }
void LlmMathText::setLatex(const QString& value) { void LlmMathText::setLatex(const QString& value) {
if (m_latex == value) if (m_latex == value) return;
return;
m_latex = value; m_latex = value;
reRender(); reRender();
} }
void LlmMathText::setColor(const QColor& value) { void LlmMathText::setColor(const QColor& value) {
if (m_color == value) if (m_color == value) return;
return;
m_color = value; m_color = value;
reRender(); reRender();
} }
void LlmMathText::setFontPointSize(double value) { void LlmMathText::setFontPointSize(double value) {
if (qFuzzyCompare(m_fontPointSize, value)) if (qFuzzyCompare(m_fontPointSize, value)) return;
return;
m_fontPointSize = value; m_fontPointSize = value;
reRender(); reRender();
} }
void LlmMathText::setDevicePixelRatio(qreal value) { void LlmMathText::setDevicePixelRatio(qreal value) {
if (qFuzzyCompare(m_devicePixelRatio, value)) if (qFuzzyCompare(m_devicePixelRatio, value)) return;
return;
m_devicePixelRatio = value; m_devicePixelRatio = value;
reRender(); reRender();
} }
namespace { 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 { struct MathRender {
bool ok = false; bool ok = false;
QImage image; QImage image;
@@ -154,10 +144,9 @@ void LlmMathText::reRender() {
return; return;
} }
const QString key = m_latex const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() +
+ QLatin1Char(0x1f) + m_color.name() QLatin1Char(0x1f) + QString::number(m_fontPointSize) +
+ QLatin1Char(0x1f) + QString::number(m_fontPointSize) QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
+ QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
if (auto it = mathCache().find(key); it != mathCache().end()) { if (auto it = mathCache().find(key); it != mathCache().end()) {
m_image = it->image; m_image = it->image;
m_imageUrl = it->url; m_imageUrl = it->url;
@@ -168,83 +157,70 @@ void LlmMathText::reRender() {
return; return;
} }
// A render is already running; it re-renders the latest state when if (m_inFlight) return;
// it completes (id mismatch), so there is nothing to do here.
if (m_inFlight)
return;
m_inFlight = true; m_inFlight = true;
const QString latex = m_latex; const QString latex = m_latex;
const QColor color = m_color; const QColor color = m_color;
const double pointSize = m_fontPointSize; const double pointSize = m_fontPointSize;
const qreal dpr = m_devicePixelRatio; 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; auto renderer = m_renderer;
QThreadPool::globalInstance()->start([this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() { QThreadPool::globalInstance()->start(
MathRender render; [this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
renderer->setFontPointSize(pointSize); MathRender render;
renderer->setFontColor(color); renderer->setFontPointSize(pointSize);
if (renderer->parse( renderer->setFontColor(color);
latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) { if (renderer->parse(
const QImage image = renderer->drawIntoImage( latex,
/* drawBoxes */ false, JKQTMathText::LatexParser,
QColor(Qt::transparent), JKQTMathText::DefaultParseOptions)) {
kRenderMargin, const QImage image = renderer->drawIntoImage(
dpr, /* drawBoxes */ false,
kResolutionDpi); QColor(Qt::transparent),
if (!image.isNull()) { kRenderMargin,
QByteArray png; dpr,
{ kResolutionDpi);
QBuffer buffer(&png); if (!image.isNull()) {
buffer.open(QIODevice::WriteOnly); QByteArray png;
image.save(&buffer, "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;
} }
} QPointer<LlmMathText> guard(this);
// Deliver through the app instance (never destroyed) and QMetaObject::invokeMethod(
// re-check the pointer on the GUI thread: posting to `this` QCoreApplication::instance(),
// from the pool thread would race with its destruction. [guard, id, key, render = std::move(render)]() mutable {
QPointer<LlmMathText> guard(this); LlmMathText* self = guard;
QMetaObject::invokeMethod( if (!self) return;
QCoreApplication::instance(), self->m_inFlight = false;
[guard, id, key, render = std::move(render)]() mutable { if (id != self->m_requestId) {
LlmMathText* self = guard; self->reRender();
if (!self) return;
return; }
self->m_inFlight = false; if (render.ok) {
if (id != self->m_requestId) { auto& cache = mathCache();
// Superseded while the worker ran; render the if (cache.size() >= kCacheLimit) cache.clear();
// latest state. cache.insert(key, render);
self->reRender(); }
return; self->m_image = render.image;
} self->m_imageUrl = render.url;
if (render.ok) { self->m_width = render.width;
auto& cache = mathCache(); self->m_height = render.height;
if (cache.size() >= kCacheLimit) self->m_ok = render.ok;
cache.clear(); Q_EMIT self->changed();
cache.insert(key, render); },
} Qt::QueuedConnection);
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 } // namespace ZShell::llm
+6 -17
View File
@@ -13,27 +13,20 @@
namespace ZShell::llm { 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 { class LlmMathText : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed) Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed)
Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed) Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed)
Q_PROPERTY(double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY changed) Q_PROPERTY(
Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio NOTIFY changed) 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) 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) 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 width READ width NOTIFY changed)
Q_PROPERTY(qreal height READ height NOTIFY changed) Q_PROPERTY(qreal height READ height NOTIFY changed)
Q_PROPERTY(bool ok READ ok NOTIFY changed) Q_PROPERTY(bool ok READ ok NOTIFY changed)
@@ -61,8 +54,6 @@ class LlmMathText : public QObject {
private: private:
void reRender(); 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<JKQTMathText> m_renderer; std::shared_ptr<JKQTMathText> m_renderer;
QString m_latex; QString m_latex;
QColor m_color; QColor m_color;
@@ -73,8 +64,6 @@ class LlmMathText : public QObject {
qreal m_width = 0; qreal m_width = 0;
qreal m_height = 0; qreal m_height = 0;
bool m_ok = false; bool m_ok = false;
// Bumps on every reRender; a delivery carrying an older id was
// superseded and is dropped.
int m_requestId = 0; int m_requestId = 0;
bool m_inFlight = false; bool m_inFlight = false;
}; };
+8 -15
View File
@@ -16,13 +16,12 @@ ChatSession* sessionOf(const ChatMessage* message) {
} // namespace } // namespace
ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) 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) { ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) {
auto* generation = new ChatGeneration(timestamp, this); auto* generation = new ChatGeneration(timestamp, this);
m_generations.append(generation); m_generations.append(generation);
if (m_active < 0) if (m_active < 0) m_active = static_cast<int>(m_generations.size() - 1);
m_active = static_cast<int>(m_generations.size() - 1);
Q_EMIT generationsChanged(); Q_EMIT generationsChanged();
return generation; return generation;
} }
@@ -35,8 +34,7 @@ ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) {
void ChatMessage::removeGeneration(ChatGeneration* generation) { void ChatMessage::removeGeneration(ChatGeneration* generation) {
const int index = static_cast<int>(m_generations.indexOf(generation)); const int index = static_cast<int>(m_generations.indexOf(generation));
if (index < 0) if (index < 0) return;
return;
const bool wasActive = index == m_active; const bool wasActive = index == m_active;
m_generations.removeAt(index); m_generations.removeAt(index);
delete generation; delete generation;
@@ -46,13 +44,11 @@ void ChatMessage::removeGeneration(ChatGeneration* generation) {
m_active = static_cast<int>(m_generations.size() - 1); m_active = static_cast<int>(m_generations.size() - 1);
} }
Q_EMIT generationsChanged(); Q_EMIT generationsChanged();
if (wasActive) if (wasActive) Q_EMIT activeGenerationChanged();
Q_EMIT activeGenerationChanged();
} }
void ChatMessage::setActiveInternal(int index) { void ChatMessage::setActiveInternal(int index) {
if (index < 0 || index >= m_generations.size() || index == m_active) if (index < 0 || index >= m_generations.size() || index == m_active) return;
return;
m_active = index; m_active = index;
Q_EMIT activeGenerationChanged(); Q_EMIT activeGenerationChanged();
} }
@@ -64,18 +60,15 @@ void ChatMessage::setActiveGeneration(int index) {
void ChatMessage::edit(const QString& newContent) { void ChatMessage::edit(const QString& newContent) {
if (auto* generation = activeGeneration()) if (auto* generation = activeGeneration())
generation->setContent(newContent); generation->setContent(newContent);
if (auto* session = sessionOf(this)) if (auto* session = sessionOf(this)) session->persist();
session->persist();
} }
void ChatMessage::retry() { void ChatMessage::retry() {
if (auto* session = sessionOf(this)) if (auto* session = sessionOf(this)) session->retry(this);
session->retry(this);
} }
void ChatMessage::generate() { void ChatMessage::generate() {
if (auto* session = sessionOf(this)) if (auto* session = sessionOf(this)) session->continueFrom(this);
session->continueFrom(this);
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+10 -13
View File
@@ -16,24 +16,23 @@ class ChatMessage : public QObject {
Q_PROPERTY(Role role READ role CONSTANT) Q_PROPERTY(Role role READ role CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(int generationCount READ generationCount NOTIFY generationsChanged)
Q_PROPERTY( Q_PROPERTY(
QList<ZShell::llm::ChatGeneration*> generations READ generations int generationCount READ generationCount NOTIFY generationsChanged)
NOTIFY generationsChanged) Q_PROPERTY(
QList<ZShell::llm::ChatGeneration*> generations READ generations NOTIFY
generationsChanged)
Q_PROPERTY( Q_PROPERTY(
ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration
NOTIFY activeGenerationChanged) NOTIFY activeGenerationChanged)
Q_PROPERTY(int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged) Q_PROPERTY(
int activeGenerationIndex READ activeGenerationIndex NOTIFY
activeGenerationChanged)
public: public:
enum class Role : int { enum class Role : int { User = 0, Assistant };
User = 0,
Assistant
};
Q_ENUM(Role) Q_ENUM(Role)
explicit ChatMessage( explicit ChatMessage(Role role, qint64 timestamp, QObject* parent = nullptr);
Role role, qint64 timestamp, QObject* parent = nullptr);
[[nodiscard]] Role role() const { return m_role; } [[nodiscard]] Role role() const { return m_role; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; }
@@ -48,8 +47,7 @@ class ChatMessage : public QObject {
} }
[[nodiscard]] int activeGenerationIndex() const { return m_active; } [[nodiscard]] int activeGenerationIndex() const { return m_active; }
[[nodiscard]] ChatGeneration* generation(int index) const { [[nodiscard]] ChatGeneration* generation(int index) const {
if (index < 0 || index >= m_generations.size()) if (index < 0 || index >= m_generations.size()) return nullptr;
return nullptr;
return m_generations.at(index); return m_generations.at(index);
} }
@@ -58,7 +56,6 @@ class ChatMessage : public QObject {
Q_INVOKABLE void retry(); Q_INVOKABLE void retry();
Q_INVOKABLE void generate(); Q_INVOKABLE void generate();
// Creates an empty generation; callers fill it with segments.
ChatGeneration* addGeneration(qint64 timestamp); ChatGeneration* addGeneration(qint64 timestamp);
ChatGeneration* appendGeneration(qint64 timestamp); ChatGeneration* appendGeneration(qint64 timestamp);
void removeGeneration(ChatGeneration* generation); void removeGeneration(ChatGeneration* generation);
+2 -4
View File
@@ -100,11 +100,9 @@ void ChatMessageModel::clear() {
void ChatMessageModel::loadMessages(QList<ChatMessage*> messages) { void ChatMessageModel::loadMessages(QList<ChatMessage*> messages) {
beginResetModel(); 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) for (ChatMessage* message : m_messages)
if (std::find(messages.begin(), messages.end(), message) if (std::find(messages.begin(), messages.end(), message) ==
== messages.end()) messages.end())
delete message; delete message;
m_messages = std::move(messages); m_messages = std::move(messages);
endResetModel(); endResetModel();
+2 -11
View File
@@ -12,17 +12,14 @@ namespace ZShell::llm {
class ChatSession; 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 { class ChatMessageModel : public QAbstractListModel {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
QML_UNCREATABLE("Chat message models are owned by ChatSession") QML_UNCREATABLE("Chat message models are owned by ChatSession")
Q_PROPERTY( Q_PROPERTY(
ZShell::llm::ChatMessage* lastMessage READ lastMessage ZShell::llm::ChatMessage* lastMessage READ lastMessage NOTIFY
NOTIFY lastMessageChanged) lastMessageChanged)
public: public:
explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr); explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr);
@@ -35,7 +32,6 @@ class ChatMessageModel : public QAbstractListModel {
[[nodiscard]] QHash<int, QByteArray> roleNames() const override; [[nodiscard]] QHash<int, QByteArray> roleNames() const override;
[[nodiscard]] ChatSession* session() const { return m_session; } [[nodiscard]] ChatSession* session() const { return m_session; }
// Most recent message first.
[[nodiscard]] QList<ChatMessage*> messages() const { return m_messages; } [[nodiscard]] QList<ChatMessage*> messages() const { return m_messages; }
[[nodiscard]] ChatMessage* at(int row) const; [[nodiscard]] ChatMessage* at(int row) const;
[[nodiscard]] int rowOf(const ChatMessage* message) const; [[nodiscard]] int rowOf(const ChatMessage* message) const;
@@ -44,17 +40,12 @@ class ChatMessageModel : public QAbstractListModel {
return m_messages.isEmpty() ? nullptr : m_messages.first(); 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); 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* appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp); ChatMessage::Role role, const QString& content, qint64 timestamp);
void removeMessage(ChatMessage* message); void removeMessage(ChatMessage* message);
void removeRange(int firstRow, int lastRow); void removeRange(int firstRow, int lastRow);
void clear(); void clear();
// Replaces every row; takes ownership of the given messages, most recent
// first.
void loadMessages(QList<ChatMessage*> messages); void loadMessages(QList<ChatMessage*> messages);
signals: signals:
+21 -52
View File
@@ -10,36 +10,27 @@
namespace ZShell::llm { namespace ZShell::llm {
namespace { namespace {
// Re-parse cadence while a segment streams; content between refreshes is
// at most this stale.
constexpr int kMarkdownRefreshMs = 150; constexpr int kMarkdownRefreshMs = 150;
} // namespace } // namespace
LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) 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); m_markdownTimer.setInterval(kMarkdownRefreshMs);
connect( connect(&m_markdownTimer, &QTimer::timeout, this, [this]() {
&m_markdownTimer, &QTimer::timeout, this, [this]() { if (!m_markdownDirty) return;
if (!m_markdownDirty) if (parseMarkdown()) m_markdownDirty = false;
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;
});
} }
qint64 LlmSegment::elapsedMs() const { qint64 LlmSegment::elapsedMs() const {
if (m_startedAt <= 0) if (m_startedAt <= 0) return 0;
return 0;
const qint64 end = m_endedAt > 0 ? m_endedAt const qint64 end = m_endedAt > 0 ? m_endedAt
: QDateTime::currentMSecsSinceEpoch(); : QDateTime::currentMSecsSinceEpoch();
return end - m_startedAt; return end - m_startedAt;
} }
void LlmSegment::begin() { void LlmSegment::begin() {
if (m_running) if (m_running) return;
return;
m_running = true; m_running = true;
m_startedAt = QDateTime::currentMSecsSinceEpoch(); m_startedAt = QDateTime::currentMSecsSinceEpoch();
m_endedAt = 0; m_endedAt = 0;
@@ -54,62 +45,52 @@ void LlmSegment::close() {
Q_EMIT runningChanged(); Q_EMIT runningChanged();
Q_EMIT elapsedMsChanged(); 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) { if (m_type == Type::Content && m_markdownDirty) {
m_markdownTimer.stop(); m_markdownTimer.stop();
if (parseMarkdown()) if (parseMarkdown()) m_markdownDirty = false;
m_markdownDirty = false;
} }
} }
void LlmSegment::appendText(const QString& piece) { void LlmSegment::appendText(const QString& piece) {
if (piece.isEmpty()) if (piece.isEmpty()) return;
return;
m_text += piece; m_text += piece;
Q_EMIT textChanged(); Q_EMIT textChanged();
scheduleMarkdown(); scheduleMarkdown();
} }
void LlmSegment::setText(const QString& value) { void LlmSegment::setText(const QString& value) {
if (m_text == value) if (m_text == value) return;
return;
m_text = value; m_text = value;
Q_EMIT textChanged(); Q_EMIT textChanged();
scheduleMarkdown(); scheduleMarkdown();
} }
void LlmSegment::setName(const QString& value) { void LlmSegment::setName(const QString& value) {
if (m_name == value) if (m_name == value) return;
return;
m_name = value; m_name = value;
Q_EMIT nameChanged(); Q_EMIT nameChanged();
} }
void LlmSegment::setToolCallId(const QString& value) { void LlmSegment::setToolCallId(const QString& value) {
if (m_toolCallId == value) if (m_toolCallId == value) return;
return;
m_toolCallId = value; m_toolCallId = value;
Q_EMIT toolCallIdChanged(); Q_EMIT toolCallIdChanged();
} }
void LlmSegment::appendArguments(const QString& piece) { void LlmSegment::appendArguments(const QString& piece) {
if (piece.isEmpty()) if (piece.isEmpty()) return;
return;
m_arguments += piece; m_arguments += piece;
Q_EMIT argumentsChanged(); Q_EMIT argumentsChanged();
} }
void LlmSegment::setResult(const QString& value) { void LlmSegment::setResult(const QString& value) {
if (m_result == value) if (m_result == value) return;
return;
m_result = value; m_result = value;
Q_EMIT resultChanged(); Q_EMIT resultChanged();
} }
void LlmSegment::setStatus(Status value) { void LlmSegment::setStatus(Status value) {
if (m_status == value) if (m_status == value) return;
return;
m_status = value; m_status = value;
Q_EMIT statusChanged(); Q_EMIT statusChanged();
} }
@@ -126,39 +107,27 @@ void LlmSegment::restore(qint64 elapsedMs) {
} }
void LlmSegment::scheduleMarkdown() { void LlmSegment::scheduleMarkdown() {
// Content segments only; reasoning/tool output is never parsed. if (m_type != Type::Content) return;
// (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;
m_markdownDirty = true; m_markdownDirty = true;
if (!m_markdownTimer.isActive()) if (!m_markdownTimer.isActive()) m_markdownTimer.start();
m_markdownTimer.start();
} }
bool LlmSegment::parseMarkdown() { bool LlmSegment::parseMarkdown() {
if (m_parseInFlight) if (m_parseInFlight) return false;
return false;
m_parseInFlight = true; m_parseInFlight = true;
const QString text = m_text; const QString text = m_text;
QThreadPool::globalInstance()->start([this, text]() { QThreadPool::globalInstance()->start([this, text]() {
const QVariantList blocks = MarkdownParser::parse(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<LlmSegment> guard(this); QPointer<LlmSegment> guard(this);
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
QCoreApplication::instance(), QCoreApplication::instance(),
[guard, blocks]() { [guard, blocks]() {
LlmSegment* seg = guard; LlmSegment* seg = guard;
if (!seg) if (!seg) return;
return;
seg->m_parseInFlight = false; seg->m_parseInFlight = false;
seg->m_markdown = blocks; seg->m_markdown = blocks;
Q_EMIT seg->markdownChanged(); 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); Qt::QueuedConnection);
}); });
+2 -25
View File
@@ -8,10 +8,6 @@
namespace ZShell::llm { 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 { class LlmSegment : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -27,26 +23,13 @@ class LlmSegment : public QObject {
Q_PROPERTY(Status status READ status NOTIFY statusChanged) Q_PROPERTY(Status status READ status NOTIFY statusChanged)
Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged) 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) Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged)
public: public:
enum class Type : int { enum class Type : int { Reasoning = 0, ToolCall, Content };
Reasoning = 0,
ToolCall,
Content
};
Q_ENUM(Type) Q_ENUM(Type)
enum class Status : int { enum class Status : int { None = 0, Running, Success, Error };
None = 0,
Running,
Success,
Error
};
Q_ENUM(Status) Q_ENUM(Status)
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
@@ -63,9 +46,7 @@ class LlmSegment : public QObject {
[[nodiscard]] qint64 elapsedMs() const; [[nodiscard]] qint64 elapsedMs() const;
[[nodiscard]] QVariantList markdown() const { return m_markdown; } [[nodiscard]] QVariantList markdown() const { return m_markdown; }
// Starts the segment's clock; a no-op while already running.
void begin(); void begin();
// Stops the segment's clock; a no-op when not running.
void close(); void close();
void appendText(const QString& piece); void appendText(const QString& piece);
void setText(const QString& value); void setText(const QString& value);
@@ -74,9 +55,7 @@ class LlmSegment : public QObject {
void appendArguments(const QString& piece); void appendArguments(const QString& piece);
void setResult(const QString& value); void setResult(const QString& value);
void setStatus(Status value); void setStatus(Status value);
// Completes a tool call with the model-facing result text.
void finishTool(const QString& resultText, bool success); void finishTool(const QString& resultText, bool success);
// Restores persisted timing without a live clock.
void restore(qint64 elapsedMs); void restore(qint64 elapsedMs);
Q_SIGNALS: Q_SIGNALS:
@@ -92,8 +71,6 @@ class LlmSegment : public QObject {
private: private:
void scheduleMarkdown(); 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(); bool parseMarkdown();
Type m_type; Type m_type;
+3 -6
View File
@@ -145,10 +145,10 @@ void ChatSession::startGeneration(ChatMessage* target) {
if (isLoaded()) { if (isLoaded()) {
clientObject->startGeneration(this, generation); clientObject->startGeneration(this, generation);
} else { } else {
// The store load is still in flight; the request needs the
// full history, so start once it lands.
connect( connect(
this, &ChatSession::loaded, clientObject, this,
&ChatSession::loaded,
clientObject,
[this, generation, clientObject]() { [this, generation, clientObject]() {
clientObject->startGeneration(this, generation); clientObject->startGeneration(this, generation);
}); });
@@ -192,8 +192,6 @@ void ChatSession::retry(ChatMessage* target) {
const int row = m_model->rowOf(target); const int row = m_model->rowOf(target);
if (row < 0) return; 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); m_model->removeRange(0, row - 1);
if (m_model->rowCount() < 2) return; if (m_model->rowCount() < 2) return;
@@ -233,7 +231,6 @@ void ChatSession::clear() {
} }
} }
if (!isLoaded()) { if (!isLoaded()) {
// The load lands shortly; drop everything once it does.
m_clearPending = true; m_clearPending = true;
return; return;
} }
-7
View File
@@ -44,14 +44,9 @@ class ChatSession : public QObject {
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
[[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] bool pinned() const { return m_pinned; }
[[nodiscard]] int messageCount() const { return m_messageCount; } [[nodiscard]] int messageCount() const { return m_messageCount; }
// The messages model; the first access starts the (async) load
// from the store.
[[nodiscard]] ChatMessageModel* messagesModel(); [[nodiscard]] ChatMessageModel* messagesModel();
void ensureLoaded(); void ensureLoaded();
// True once the async load from the store has finished.
[[nodiscard]] bool isLoaded() const { return m_loaded; } [[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; } [[nodiscard]] ChatMessageModel* model() const { return m_model; }
void markLoaded(); void markLoaded();
[[nodiscard]] bool takeClearPending(); [[nodiscard]] bool takeClearPending();
@@ -71,7 +66,6 @@ class ChatSession : public QObject {
qint64 updatedAt, qint64 updatedAt,
int messageCount); int messageCount);
// Replaces the model's rows with `messages` (most recent first).
void adoptMessages(QList<ChatMessage*> messages); void adoptMessages(QList<ChatMessage*> messages);
void persist(); void persist();
void removeMessage(ChatMessage* message); void removeMessage(ChatMessage* message);
@@ -88,7 +82,6 @@ class ChatSession : public QObject {
void updatedAtChanged(); void updatedAtChanged();
void pinnedChanged(); void pinnedChanged();
void messageCountChanged(); void messageCountChanged();
// The messages finished loading from the store.
void loaded(); void loaded();
private: private:
+4 -8
View File
@@ -22,29 +22,25 @@ QJsonObject LlmTool::specification() const {
ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {} ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {}
void ToolRegistry::setEnabled(bool value) { void ToolRegistry::setEnabled(bool value) {
if (m_enabled == value) if (m_enabled == value) return;
return;
m_enabled = value; m_enabled = value;
Q_EMIT enabledChanged(); Q_EMIT enabledChanged();
} }
void ToolRegistry::registerTool(LlmTool* tool) { void ToolRegistry::registerTool(LlmTool* tool) {
if (!tool || m_tools.contains(tool)) if (!tool || m_tools.contains(tool)) return;
return;
tool->setParent(this); tool->setParent(this);
m_tools.append(tool); m_tools.append(tool);
} }
LlmTool* ToolRegistry::tool(const QString& name) const { LlmTool* ToolRegistry::tool(const QString& name) const {
for (const auto* tool : m_tools) for (const auto* tool : m_tools)
if (tool->name() == name) if (tool->name() == name) return const_cast<LlmTool*>(tool);
return const_cast<LlmTool*>(tool);
return nullptr; return nullptr;
} }
QJsonArray ToolRegistry::specifications() const { QJsonArray ToolRegistry::specifications() const {
if (!m_enabled) if (!m_enabled) return {};
return {};
QJsonArray specs; QJsonArray specs;
for (const auto* tool : m_tools) for (const auto* tool : m_tools)
specs.append(tool->specification()); specs.append(tool->specification());
-14
View File
@@ -10,9 +10,6 @@
namespace ZShell::llm { 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 { class LlmTool : public QObject {
Q_OBJECT Q_OBJECT
@@ -22,24 +19,16 @@ class LlmTool : public QObject {
[[nodiscard]] virtual QString name() const = 0; [[nodiscard]] virtual QString name() const = 0;
[[nodiscard]] virtual QString description() const = 0; [[nodiscard]] virtual QString description() const = 0;
// JSON Schema describing the tool's `arguments` object.
[[nodiscard]] virtual QJsonObject parameters() const = 0; [[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( virtual void execute(
const QJsonObject& args, const QJsonObject& args,
std::function<void(const QJsonObject& result)> done) = 0; std::function<void(const QJsonObject& result)> done) = 0;
// Abandons in-flight work, if any.
virtual void cancel(); virtual void cancel();
// The OpenAI-compatible `tools` entry for this tool.
[[nodiscard]] QJsonObject specification() const; [[nodiscard]] QJsonObject specification() const;
}; };
// Owns the set of tools available to the model.
class ToolRegistry : public QObject { class ToolRegistry : public QObject {
Q_OBJECT Q_OBJECT
@@ -51,12 +40,9 @@ class ToolRegistry : public QObject {
[[nodiscard]] bool enabled() const { return m_enabled; } [[nodiscard]] bool enabled() const { return m_enabled; }
void setEnabled(bool value); void setEnabled(bool value);
// Takes ownership; tools become children of the registry.
void registerTool(LlmTool* tool); void registerTool(LlmTool* tool);
[[nodiscard]] LlmTool* tool(const QString& name) const; [[nodiscard]] LlmTool* tool(const QString& name) const;
// The request body's `tools` array; empty while disabled.
[[nodiscard]] QJsonArray specifications() const; [[nodiscard]] QJsonArray specifications() const;
// Abandons in-flight work in every tool.
void cancelAll(); void cancelAll();
Q_SIGNALS: Q_SIGNALS:
+69 -91
View File
@@ -32,12 +32,9 @@ WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {}
WebFetchTool::~WebFetchTool() { WebFetchTool::~WebFetchTool() {
for (auto* job : m_jobs) { for (auto* job : m_jobs) {
if (job->timer) if (job->timer) job->timer->stop();
job->timer->stop(); if (job->reply) job->reply->abort();
if (job->reply)
job->reply->abort();
} }
// Pending result callbacks are dropped; the client is going away.
qDeleteAll(m_jobs); qDeleteAll(m_jobs);
} }
@@ -64,9 +61,9 @@ QJsonObject WebFetchTool::parameters() const {
QJsonObject format; QJsonObject format;
format[QStringLiteral("type")] = QStringLiteral("string"); format[QStringLiteral("type")] = QStringLiteral("string");
format[QStringLiteral("enum")] = formats; format[QStringLiteral("enum")] = formats;
format[QStringLiteral("description")] = format[QStringLiteral("description")] = QStringLiteral(
QStringLiteral("The format to return the content in. Defaults to " "The format to return the content in. Defaults to "
"text."); "text.");
QJsonObject timeout; QJsonObject timeout;
timeout[QStringLiteral("type")] = QStringLiteral("integer"); timeout[QStringLiteral("type")] = QStringLiteral("integer");
@@ -90,18 +87,14 @@ QJsonObject WebFetchTool::parameters() const {
} }
void WebFetchTool::completeJob(Job* job, QJsonObject result) { 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( QMetaObject::invokeMethod(
this, this,
[this, job, result = std::move(result)]() mutable { [this, job, result = std::move(result)]() mutable {
if (!m_jobs.contains(job)) if (!m_jobs.contains(job)) return;
return;
auto done = std::move(job->done); auto done = std::move(job->done);
m_jobs.removeAll(job); m_jobs.removeAll(job);
job->timer->deleteLater(); job->timer->deleteLater();
if (job->reply) if (job->reply) job->reply->deleteLater();
job->reply->deleteLater();
delete job; delete job;
done(std::move(result)); done(std::move(result));
}, },
@@ -126,7 +119,7 @@ void WebFetchTool::execute(
return; return;
} }
if (url.scheme() != QLatin1String("http") && if (url.scheme() != QLatin1String("http") &&
url.scheme() != QLatin1String("https")) { url.scheme() != QLatin1String("https")) {
fail(QStringLiteral("URL must use http:// or https://")); fail(QStringLiteral("URL must use http:// or https://"));
return; return;
} }
@@ -136,20 +129,18 @@ void WebFetchTool::execute(
if (job->format != QLatin1String("html")) if (job->format != QLatin1String("html"))
job->format = QStringLiteral("text"); job->format = QStringLiteral("text");
const int timeoutMs = qBound( const int timeoutMs =
1, qBound(
args[QStringLiteral("timeout")].toInt( 1,
DefaultTimeoutSeconds), args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds),
MaxTimeoutSeconds) * MaxTimeoutSeconds) *
1000; 1000;
job->timer = new QTimer(this); job->timer = new QTimer(this);
job->timer->setSingleShot(true); job->timer->setSingleShot(true);
connect( connect(job->timer, &QTimer::timeout, this, [this, job]() {
job->timer, &QTimer::timeout, this, [this, job]() { if (job->reply) job->reply->abort();
if (job->reply) });
job->reply->abort();
});
job->timer->start(timeoutMs); job->timer->start(timeoutMs);
QNetworkRequest request(url); QNetworkRequest request(url);
@@ -159,13 +150,12 @@ void WebFetchTool::execute(
job->format == QLatin1String("html") job->format == QLatin1String("html")
? "text/html;q=1.0, application/xhtml+xml;q=0.9, */*;q=0.1" ? "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, " : "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"); request.setRawHeader("Accept-Language", "en-US,en;q=0.9");
job->reply = m_manager.get(request); job->reply = m_manager.get(request);
connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() { connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() {
if (!job->reply) if (!job->reply) return;
return;
job->body += job->reply->readAll(); job->body += job->reply->readAll();
if (job->body.size() > MaxResponseBytes) { if (job->body.size() > MaxResponseBytes) {
job->tooLarge = true; job->tooLarge = true;
@@ -176,8 +166,7 @@ void WebFetchTool::execute(
QNetworkReply* reply = job->reply; QNetworkReply* reply = job->reply;
job->reply = nullptr; job->reply = nullptr;
job->timer->stop(); job->timer->stop();
if (!reply) if (!reply) return;
return;
const QNetworkReply::NetworkError error = reply->error(); const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString(); const QString errorString = reply->errorString();
@@ -187,13 +176,14 @@ void WebFetchTool::execute(
const QByteArray contentType = const QByteArray contentType =
reply->rawHeader("Content-Type").toLower(); reply->rawHeader("Content-Type").toLower();
const int status = const int status =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
.toInt();
if (job->tooLarge) { if (job->tooLarge) {
completeJob(job, makeError( completeJob(
QStringLiteral("Response too large (exceeds the 5 MB " job,
"limit"))); makeError(QStringLiteral(
"Response too large (exceeds the 5 MB "
"limit")));
return; return;
} }
if (error != QNetworkReply::NoError) { if (error != QNetworkReply::NoError) {
@@ -207,25 +197,23 @@ void WebFetchTool::execute(
completeJob( completeJob(
job, job,
makeError( makeError(
QStringLiteral("Server returned status %1") QStringLiteral("Server returned status %1").arg(status)));
.arg(status)));
return; return;
} }
const QString mime = const QString mime = QString::fromLatin1(contentType)
QString::fromLatin1(contentType).section(QLatin1Char(';'), 0, 0) .section(QLatin1Char(';'), 0, 0)
.trimmed(); .trimmed();
if (mime.startsWith(QLatin1String("image/"))) { if (mime.startsWith(QLatin1String("image/"))) {
completeJob( completeJob(
job, job,
makeError( makeError(
QStringLiteral( QStringLiteral("Unsupported fetched image content type: %1")
"Unsupported fetched image content type: %1")
.arg(mime))); .arg(mime)));
return; return;
} }
const bool textual = mime.isEmpty() || const bool textual =
mime.startsWith(QLatin1String("text/")) || mime.isEmpty() || mime.startsWith(QLatin1String("text/")) ||
mime == QLatin1String("application/json") || mime == QLatin1String("application/json") ||
mime.endsWith(QLatin1String("+json")) || mime.endsWith(QLatin1String("+json")) ||
mime == QLatin1String("application/xml") || mime == QLatin1String("application/xml") ||
@@ -236,19 +224,18 @@ void WebFetchTool::execute(
completeJob( completeJob(
job, job,
makeError( makeError(
QStringLiteral( QStringLiteral("Unsupported fetched file content type: %1")
"Unsupported fetched file content type: %1")
.arg(mime))); .arg(mime)));
return; return;
} }
QString content = QString::fromUtf8(body); QString content = QString::fromUtf8(body);
if (mime.contains(QLatin1String("text/html")) && if (mime.contains(QLatin1String("text/html")) &&
job->format == QLatin1String("text")) job->format == QLatin1String("text"))
content = extractTextFromHtml(content); content = extractTextFromHtml(content);
if (content.size() > MaxOutputChars) if (content.size() > MaxOutputChars)
content = content.left(MaxOutputChars) + content = content.left(MaxOutputChars) +
QStringLiteral("\n[... truncated ...]"); QStringLiteral("\n[... truncated ...]");
completeJob(job, makeOutput(content)); completeJob(job, makeOutput(content));
}); });
} }
@@ -256,14 +243,12 @@ void WebFetchTool::execute(
void WebFetchTool::cancel() { void WebFetchTool::cancel() {
for (auto* job : m_jobs) { for (auto* job : m_jobs) {
job->timer->stop(); job->timer->stop();
if (job->reply) if (job->reply) job->reply->abort();
job->reply->abort();
} }
} }
QString WebFetchTool::decodeEntities(const QString& text) { QString WebFetchTool::decodeEntities(const QString& text) {
if (!text.contains(QLatin1Char('&'))) if (!text.contains(QLatin1Char('&'))) return text;
return text;
QString out; QString out;
out.reserve(text.size()); out.reserve(text.size());
for (qsizetype i = 0; i < text.size(); ++i) { for (qsizetype i = 0; i < text.size(); ++i) {
@@ -292,10 +277,11 @@ QString WebFetchTool::decodeEntities(const QString& text) {
replacement = QLatin1Char(' '); replacement = QLatin1Char(' ');
else { else {
bool ok = false; bool ok = false;
const quint32 codePoint = entity.startsWith(QLatin1String("#x")) || const quint32 codePoint =
entity.startsWith(QLatin1String("#X")) entity.startsWith(QLatin1String("#x")) ||
? entity.mid(2).toUInt(&ok, 16) entity.startsWith(QLatin1String("#X"))
: entity.toUInt(&ok); ? entity.mid(2).toUInt(&ok, 16)
: entity.toUInt(&ok);
if (ok && codePoint != 0) { if (ok && codePoint != 0) {
const char32_t ucs4[2] = { const char32_t ucs4[2] = {
static_cast<char32_t>(codePoint), static_cast<char32_t>(codePoint),
@@ -326,12 +312,18 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
QStringLiteral("style"), QStringLiteral("style"),
}; };
static const QSet<QString> kVoidTags = { static const QSet<QString> kVoidTags = {
QStringLiteral("area"), QStringLiteral("base"), QStringLiteral("area"),
QStringLiteral("br"), QStringLiteral("col"), QStringLiteral("base"),
QStringLiteral("embed"), QStringLiteral("hr"), QStringLiteral("br"),
QStringLiteral("img"), QStringLiteral("input"), QStringLiteral("col"),
QStringLiteral("link"), QStringLiteral("meta"), QStringLiteral("embed"),
QStringLiteral("source"), QStringLiteral("track"), QStringLiteral("hr"),
QStringLiteral("img"),
QStringLiteral("input"),
QStringLiteral("link"),
QStringLiteral("meta"),
QStringLiteral("source"),
QStringLiteral("track"),
QStringLiteral("wbr"), QStringLiteral("wbr"),
}; };
@@ -342,55 +334,43 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
while (i < html.size()) { while (i < html.size()) {
const qsizetype open = html.indexOf(QLatin1Char('<'), i); const qsizetype open = html.indexOf(QLatin1Char('<'), i);
if (open < 0) { if (open < 0) {
if (skipDepth == 0) if (skipDepth == 0) text += html.mid(i);
text += html.mid(i);
break; break;
} }
if (skipDepth == 0) if (skipDepth == 0) text += html.mid(i, open - i);
text += html.mid(i, open - i);
const qsizetype close = html.indexOf(QLatin1Char('>'), open); const qsizetype close = html.indexOf(QLatin1Char('>'), open);
if (close < 0) if (close < 0) break;
break;
const QString tag = const QString tag =
html.mid(open + 1, close - open - 1).trimmed().toLower(); html.mid(open + 1, close - open - 1).trimmed().toLower();
i = close + 1; i = close + 1;
if (tag.startsWith(QLatin1Char('!')) || if (tag.startsWith(QLatin1Char('!')) ||
tag.startsWith(QLatin1Char('?'))) tag.startsWith(QLatin1Char('?')))
continue; continue;
QString name = tag; QString name = tag;
if (name.startsWith(QLatin1Char('/'))) { if (name.startsWith(QLatin1Char('/'))) {
if (skipDepth > 0) if (skipDepth > 0) --skipDepth;
--skipDepth;
continue; continue;
} }
qsizetype j = 0; qsizetype j = 0;
while (j < name.size() && while (j < name.size() && (name.at(j).isLetterOrNumber() ||
(name.at(j).isLetterOrNumber() || name.at(j) == QLatin1Char(':') ||
name.at(j) == QLatin1Char(':') || name.at(j) == QLatin1Char('-')))
name.at(j) == QLatin1Char('-')))
++j; ++j;
name = name.left(j); name = name.left(j);
if (kRawTags.contains(name)) { if (kRawTags.contains(name)) {
// Raw-text element: swallow everything up to its close tag. const qsizetype rawEnd = html.indexOf(
const qsizetype rawEnd = QStringLiteral("</") + name, i, Qt::CaseInsensitive);
html.indexOf(QStringLiteral("</") + name, i, if (rawEnd < 0) break;
Qt::CaseInsensitive);
if (rawEnd < 0)
break;
const qsizetype rawClose = html.indexOf(QLatin1Char('>'), rawEnd); const qsizetype rawClose = html.indexOf(QLatin1Char('>'), rawEnd);
if (rawClose < 0) if (rawClose < 0) break;
break;
i = rawClose + 1; i = rawClose + 1;
continue; continue;
} }
if (kVoidTags.contains(name)) if (kVoidTags.contains(name)) continue;
continue;
if (skipDepth > 0) { if (skipDepth > 0) {
// Browsers implicitly close <head> at <body>; malformed pages
// without a </head> would otherwise swallow the whole page.
if (name == QLatin1String("body")) { if (name == QLatin1String("body")) {
skipDepth = 0; skipDepth = 0;
continue; continue;
@@ -402,7 +382,6 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
++skipDepth; ++skipDepth;
continue; continue;
} }
// Normal tag: replace with a space so words do not merge.
text += QLatin1Char(' '); text += QLatin1Char(' ');
} }
@@ -411,8 +390,7 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
for (const QString& line : out.split(QLatin1Char('\n'))) { for (const QString& line : out.split(QLatin1Char('\n'))) {
const QString flat = line.simplified(); const QString flat = line.simplified();
if (flat.isEmpty()) { if (flat.isEmpty()) {
if (!lines.isEmpty() && lines.last().isEmpty()) if (!lines.isEmpty() && lines.last().isEmpty()) continue;
continue;
lines.append(QString()); lines.append(QString());
} else { } else {
lines.append(flat); lines.append(flat);
-9
View File
@@ -13,11 +13,6 @@ class QTimer;
namespace ZShell::llm { 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 { class WebFetchTool : public LlmTool {
Q_OBJECT Q_OBJECT
@@ -25,8 +20,6 @@ class WebFetchTool : public LlmTool {
static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
static constexpr int DefaultTimeoutSeconds = 30; static constexpr int DefaultTimeoutSeconds = 30;
static constexpr int MaxTimeoutSeconds = 120; 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; static constexpr int MaxOutputChars = 64 * 1024;
explicit WebFetchTool(QObject* parent = nullptr); explicit WebFetchTool(QObject* parent = nullptr);
@@ -40,8 +33,6 @@ class WebFetchTool : public LlmTool {
std::function<void(const QJsonObject& result)> done) override; std::function<void(const QJsonObject& result)> done) override;
void cancel() 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 extractTextFromHtml(const QString& html);
static QString decodeEntities(const QString& text); static QString decodeEntities(const QString& text);