new chatlistview component, file attachments, performance & primitive test

This commit is contained in:
2026-09-30 15:01:28 +02:00
parent 51ed270774
commit d8a3d5732e
36 changed files with 4368 additions and 310 deletions
+2
View File
@@ -285,6 +285,7 @@ file(WRITE "${LM_FONTS_HPP}" "${_lm_header}")
qml_module(ZShell-llm
URI ZShell.Llm
SOURCES
attachment.hpp attachment.cpp
chat.hpp chat.cpp
chatstore.hpp chatstore.cpp
codehighlighter.hpp codehighlighter.cpp
@@ -298,6 +299,7 @@ qml_module(ZShell-llm
messagemodel.hpp messagemodel.cpp
segment.hpp segment.cpp
session.hpp session.cpp
shelltool.hpp shelltool.cpp
tool.hpp tool.cpp
webfetchtool.hpp webfetchtool.cpp
LIBRARIES
+19
View File
@@ -0,0 +1,19 @@
#include "attachment.hpp"
namespace ZShell::llm {
Attachment::Attachment(
const QString& name,
const QString& path,
Kind kind,
const QString& mimeType,
qint64 size,
QObject* parent)
: QObject(parent)
, m_name(name)
, m_path(path)
, m_kind(kind)
, m_mimeType(mimeType)
, m_size(size) {}
} // namespace ZShell::llm
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <QObject>
#include <QString>
#include <QtQml>
namespace ZShell::llm {
class Attachment : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Attachments are created by ChatSession.addAttachments")
Q_PROPERTY(QString name READ name CONSTANT)
Q_PROPERTY(QString path READ path CONSTANT)
Q_PROPERTY(Kind kind READ kind CONSTANT)
Q_PROPERTY(bool isImage READ isImage CONSTANT)
Q_PROPERTY(QString mimeType READ mimeType CONSTANT)
Q_PROPERTY(qint64 size READ size CONSTANT)
public:
enum class Kind : int { Image = 0, File = 1 };
Q_ENUM(Kind)
Attachment(
const QString& name,
const QString& path,
Kind kind,
const QString& mimeType,
qint64 size,
QObject* parent = nullptr);
[[nodiscard]] QString name() const { return m_name; }
[[nodiscard]] QString path() const { return m_path; }
[[nodiscard]] Kind kind() const { return m_kind; }
[[nodiscard]] bool isImage() const { return m_kind == Kind::Image; }
[[nodiscard]] QString mimeType() const { return m_mimeType; }
[[nodiscard]] qint64 size() const { return m_size; }
private:
QString m_name;
QString m_path;
Kind m_kind;
QString m_mimeType;
qint64 m_size = 0;
};
} // namespace ZShell::llm
+38 -10
View File
@@ -4,6 +4,7 @@
#include "filetool.hpp"
#include "llm.hpp"
#include "llmclient.hpp"
#include "shelltool.hpp"
#include "webfetchtool.hpp"
#include <QDebug>
@@ -19,12 +20,14 @@ Chat::Chat(QObject* parent)
m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
m_client->tools()->registerTool(new ShellTool(m_client, m_client->tools()));
const auto* llm = config::Config::instance()->llm();
m_client->setEndpoint(llm->endpoint());
m_client->setModel(llm->model());
m_client->setTemperature(llm->temperature());
m_client->setToolsEnabled(llm->tools());
m_client->setSandboxEnabled(llm->sandbox());
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
m_client->setEndpoint(llm->endpoint());
@@ -38,11 +41,16 @@ Chat::Chat(QObject* parent)
connect(llm, &config::Llm::toolsChanged, this, [this, llm]() {
m_client->setToolsEnabled(llm->tools());
});
connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() {
m_client->setSandboxEnabled(llm->sandbox());
Q_EMIT sandboxEnabledChanged();
});
connect(m_client, &LlmClient::busyChanged, this, [this]() {
if (m_client->busy() && !m_lastError.isEmpty()) {
m_lastError.clear();
Q_EMIT lastErrorChanged();
if (m_client->busy() && (m_hasError || !m_errorMessage.isEmpty())) {
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
Q_EMIT busyChanged();
});
@@ -73,10 +81,16 @@ Chat::Chat(QObject* parent)
&LlmClient::errorOccurred,
this,
[this](const QString& message) {
m_lastError = message;
Q_EMIT lastErrorChanged();
m_hasError = !message.isEmpty();
m_errorMessage = message;
Q_EMIT errorChanged();
Q_EMIT errorOccurred(message);
});
connect(
m_client,
&LlmClient::serverErrorChanged,
this,
&Chat::serverErrorChanged);
connect(
m_store,
&ChatStore::sessionRemoved,
@@ -134,11 +148,24 @@ void Chat::setToolsEnabled(bool value) {
config->llm()->set_tools(value);
}
bool Chat::sandboxEnabled() const {
return m_client->sandboxEnabled();
}
void Chat::setSandboxEnabled(bool value) {
m_client->setSandboxEnabled(value);
if (auto* config = config::Config::instance())
config->llm()->set_sandbox(value);
}
QString Chat::serverError() const {
return m_client->serverError();
}
QString Chat::streamingChatId() const {
return m_client->streamingChatId();
}
Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
@@ -151,9 +178,10 @@ void Chat::stop() {
}
void Chat::dismissError() {
if (m_lastError.isEmpty()) return;
m_lastError.clear();
Q_EMIT lastErrorChanged();
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
void Chat::refreshModels() {
@@ -161,7 +189,7 @@ void Chat::refreshModels() {
}
void Chat::refreshFromServer() {
m_client->refreshFromServer();
m_client->refreshAllStates();
}
void Chat::selectModel(const QString& id) {
+16 -4
View File
@@ -29,7 +29,12 @@ class Chat : public QObject {
Q_PROPERTY(
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
toolsEnabledChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(
bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY
sandboxEnabledChanged)
Q_PROPERTY(bool error READ error NOTIFY errorChanged)
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged)
Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
Q_PROPERTY(
QString streamingChatId READ streamingChatId NOTIFY
@@ -45,7 +50,11 @@ class Chat : public QObject {
[[nodiscard]] int contextSize() const;
[[nodiscard]] bool toolsEnabled() const;
void setToolsEnabled(bool value);
[[nodiscard]] QString lastError() const { return m_lastError; }
[[nodiscard]] bool sandboxEnabled() const;
void setSandboxEnabled(bool value);
[[nodiscard]] bool error() const { return m_hasError; }
[[nodiscard]] QString errorMessage() const { return m_errorMessage; }
[[nodiscard]] QString serverError() const;
[[nodiscard]] ChatStore* chats() const { return m_store; }
[[nodiscard]] QString streamingChatId() const;
@@ -64,14 +73,17 @@ class Chat : public QObject {
void availableModelsChanged();
void contextSizeChanged();
void toolsEnabledChanged();
void sandboxEnabledChanged();
void errorOccurred(const QString& message);
void lastErrorChanged();
void errorChanged();
void serverErrorChanged();
void streamingChatIdChanged();
private:
ChatStore* m_store = nullptr;
LlmClient* m_client = nullptr;
QString m_lastError;
bool m_hasError = false;
QString m_errorMessage;
static Chat* s_instance;
};
+96
View File
@@ -65,10 +65,19 @@ struct GenerationRow {
QVector<SegmentRow> segments;
};
struct AttachmentRow {
QString name;
QString path;
int kind = 1;
QString mime;
qint64 size = 0;
};
struct MessageRow {
bool user = false;
qint64 timestamp = 0;
QVector<GenerationRow> generations;
QVector<AttachmentRow> attachments;
};
} // namespace
@@ -160,6 +169,20 @@ void ChatStore::openDb() {
" timestamp INTEGER NOT NULL\n"
")"));
}
{
QSqlQuery query(db);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS attachments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" message_id INTEGER NOT NULL REFERENCES messages "
"(id) ON DELETE CASCADE,\n"
" name TEXT NOT NULL,\n"
" path TEXT NOT NULL,\n"
" kind INTEGER NOT NULL DEFAULT 1,\n"
" mime TEXT NOT NULL DEFAULT '',\n"
" size INTEGER NOT NULL DEFAULT 0\n"
")"));
}
{
QSqlQuery query(db);
query.exec(QStringLiteral(
@@ -171,6 +194,9 @@ void ChatStore::openDb() {
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_segments_generation "
"ON segments (generation_id)"));
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_attachments_message "
"ON attachments (message_id)"));
}
}
@@ -233,6 +259,7 @@ void ChatStore::removeSession(ChatSession* session) {
query.bindValue(":id", session->id());
query.exec();
}
QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively();
m_sessions.removeOne(session);
session->deleteLater();
notify(before);
@@ -330,6 +357,11 @@ bool ChatStore::saveSession(ChatSession* session) {
"timestamp) VALUES (:gid, :type, :text, :name, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
":timestamp)");
QSqlQuery attachmentInsert(handle);
ok = ok && attachmentInsert.prepare(
"INSERT INTO attachments (message_id, name, path, kind, "
"mime, size) VALUES (:mid, :name, :path, :kind, :mime, "
":size)");
const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(row);
@@ -390,6 +422,26 @@ bool ChatStore::saveSession(ChatSession* session) {
}
}
}
if (ok)
for (const auto* attachment : message->attachments()) {
attachmentInsert.bindValue(":mid", messageId);
attachmentInsert.bindValue(
":name", sqlText(attachment->name()));
attachmentInsert.bindValue(
":path", sqlText(attachment->path()));
attachmentInsert.bindValue(
":kind", static_cast<int>(attachment->kind()));
attachmentInsert.bindValue(
":mime", sqlText(attachment->mimeType()));
attachmentInsert.bindValue(":size", attachment->size());
if (!attachmentInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "attachment insert failed:"
<< attachmentInsert.lastError().text();
break;
}
}
}
}
if (!ok || !handle.commit()) {
@@ -497,6 +549,34 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
<< messageId << ":"
<< generationQuery.lastError().text();
}
QSqlQuery attachmentQuery(db);
attachmentQuery.prepare(
"SELECT name, path, kind, mime, size FROM "
"attachments WHERE message_id = :mid "
"ORDER BY rowid");
attachmentQuery.bindValue(":mid", messageId);
if (attachmentQuery.exec()) {
while (attachmentQuery.next()) {
AttachmentRow attachment;
attachment.name =
attachmentQuery.value(0).toString();
attachment.path =
attachmentQuery.value(1).toString();
attachment.kind =
attachmentQuery.value(2).toInt();
attachment.mime =
attachmentQuery.value(3).toString();
attachment.size =
attachmentQuery.value(4).toLongLong();
message.attachments.append(attachment);
}
} else {
qWarning()
<< "ChatStore: failed to load attachments "
"for message"
<< messageId << ":"
<< attachmentQuery.lastError().text();
}
rows.append(message);
}
}
@@ -523,6 +603,22 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
row.timestamp);
if (!row.attachments.isEmpty()) {
QList<Attachment*> attachments;
for (const AttachmentRow& attachmentRow :
row.attachments) {
attachments.append(new Attachment(
attachmentRow.name,
attachmentRow.path,
attachmentRow.kind == 0
? Attachment::Kind::Image
: Attachment::Kind::File,
attachmentRow.mime,
attachmentRow.size,
message));
}
message->setAttachments(attachments);
}
int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow =
+4 -4
View File
@@ -198,7 +198,7 @@ const char* CodeHighlighter::roleName(uint8_t role) {
QString CodeHighlighter::resolveId(const QString& language) {
const QString tag = language.trimmed().toLower();
const QString alias = aliases().value(tag);
return alias.isEmpty() ? tag : alias; // unknown tags = grammar id
return alias.isEmpty() ? tag : alias;
}
QString CodeHighlighter::cacheKey(const QString& id, const QString& code) {
@@ -223,8 +223,8 @@ void CodeHighlighter::storeSpans(
const QString& language,
const QVariantList& spans) const {
if (spans.isEmpty() || code.isEmpty()) return;
static constexpr int kMaxEntries = 32;
static constexpr int kMaxBytes = 1024 * 1024;
static constexpr int kMaxEntries = 64;
static constexpr int kMaxBytes = 2 * 1024 * 1024;
const QString key = cacheKey(resolveId(language), code);
const int bytes = static_cast<int>(code.toUtf8().size());
QMutexLocker locker(&m_cacheMutex);
@@ -369,7 +369,7 @@ QVariantList CodeHighlighter::doHighlight(
if (c < 0x80)
cu[b + 1] += 1;
else if (c < 0xC0)
; // continuation byte
;
else if (c < 0xF0)
cu[b + 1] += 1;
else
+15
View File
@@ -176,6 +176,21 @@ void ChatGeneration::appendReasoning(const QString& piece) {
openReasoningSegment()->appendText(piece);
}
void ChatGeneration::setError(const QString& value) {
const bool hasError = !value.isEmpty();
if (m_hasError == hasError && m_errorMessage == value) return;
m_hasError = hasError;
m_errorMessage = value;
Q_EMIT errorChanged();
}
void ChatGeneration::dismissError() {
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
void ChatGeneration::setStreaming(bool value) {
if (m_streaming == value) return;
m_streaming = value;
+9
View File
@@ -21,6 +21,8 @@ class ChatGeneration : public QObject {
Q_PROPERTY(
QString content READ content WRITE setContent NOTIFY contentChanged)
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
Q_PROPERTY(bool error READ error NOTIFY errorChanged)
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged)
Q_PROPERTY(
qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
elapsedMsChanged)
@@ -48,6 +50,8 @@ class ChatGeneration : public QObject {
[[nodiscard]] QString content() const;
[[nodiscard]] QString reasoning() const;
[[nodiscard]] bool error() const { return m_hasError; }
[[nodiscard]] QString errorMessage() const { return m_errorMessage; }
[[nodiscard]] bool reasoningActive() const;
[[nodiscard]] qint64 reasoningElapsedMs() const;
[[nodiscard]] qint64 contentElapsedMs() const;
@@ -65,11 +69,13 @@ class ChatGeneration : public QObject {
Q_INVOKABLE void approveTools();
Q_INVOKABLE void denyTools();
Q_INVOKABLE void dismissError();
void setContent(const QString& value);
void appendContent(const QString& piece);
void appendReasoning(const QString& piece);
void setStreaming(bool value);
void setError(const QString& value);
[[nodiscard]] LlmSegment* openContentSegment();
[[nodiscard]] LlmSegment* openReasoningSegment();
@@ -81,6 +87,7 @@ class ChatGeneration : public QObject {
Q_SIGNALS:
void contentChanged();
void reasoningChanged();
void errorChanged();
void reasoningActiveChanged();
void elapsedMsChanged();
void streamingChanged();
@@ -98,6 +105,8 @@ class ChatGeneration : public QObject {
bool m_streaming = false;
bool m_approvalPending = false;
qint64 m_timestamp;
bool m_hasError = false;
QString m_errorMessage;
};
} // namespace ZShell::llm
+219 -57
View File
@@ -1,5 +1,6 @@
#include "llmclient.hpp"
#include "attachment.hpp"
#include "generation.hpp"
#include "message.hpp"
#include "messagemodel.hpp"
@@ -7,6 +8,7 @@
#include "session.hpp"
#include <QDebug>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
@@ -132,26 +134,30 @@ QString LlmClient::completionsPath(
return base + subpath;
}
QString LlmClient::jsonErrorMessage(const QByteArray& body) {
const QJsonDocument doc = QJsonDocument::fromJson(body);
if (!doc.isObject()) return QString();
const QJsonValue errorValue = doc.object().value("error");
if (errorValue.isObject())
return errorValue.toObject().value("message").toString();
if (errorValue.isString()) return errorValue.toString();
return QString();
}
QString LlmClient::serverErrorMessage(
const QByteArray& body, const QString& fallback) {
const QJsonDocument doc = QJsonDocument::fromJson(body);
if (doc.isObject()) {
const QJsonObject obj = doc.object();
if (obj.contains("error")) {
const QJsonValue errorValue = obj["error"];
if (errorValue.isObject()) {
const QString message =
errorValue.toObject()["message"].toString();
if (!message.isEmpty()) return message;
} else if (!errorValue.toString().isEmpty()) {
return errorValue.toString();
}
}
}
const QString message = jsonErrorMessage(body);
if (!message.isEmpty()) return message;
return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed")
: fallback;
}
void LlmClient::setServerError(const QString& value) {
if (m_serverError == value) return;
m_serverError = value;
Q_EMIT serverErrorChanged();
}
void LlmClient::setBusy(bool value) {
if (m_busy == value) return;
m_busy = value;
@@ -175,14 +181,17 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) {
m_refreshTimer.setParent(this);
m_refreshTimer.setInterval(kRefreshIntervalMs);
connect(
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer);
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshAllStates);
m_refreshTimer.start();
}
LlmClient::~LlmClient() {
if (m_reply) m_reply->abort();
if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
m_tools->cancelAll();
endStream();
}
void LlmClient::setEndpoint(const QString& value) {
@@ -190,7 +199,7 @@ void LlmClient::setEndpoint(const QString& value) {
m_endpoint = value;
Q_EMIT endpointChanged();
m_refreshTimer.start();
refreshFromServer();
refreshAllStates();
}
void LlmClient::setModel(const QString& value) {
@@ -204,9 +213,16 @@ void LlmClient::setTemperature(double value) {
m_temperature = value;
}
void LlmClient::setSandboxEnabled(bool value) {
m_sandboxEnabled = value;
}
QString LlmClient::activeWorkspace() const {
return m_active ? m_active->workspacePath() : QString();
}
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
if (m_busy || !session || !target) return;
refreshModels();
m_contextRetryUsed = false;
m_active = session;
m_streaming = target;
@@ -242,6 +258,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
probeContextSize([this, session, target]() {
if (m_active != session || m_streaming != target) return;
refreshModels();
sendRound();
});
}
@@ -253,6 +270,7 @@ void LlmClient::sendRound() {
m_callBuilders.clear();
m_callResults.clear();
m_roundDone = false;
m_streamDone = false;
m_toolPhase = false;
m_pendingCalls = 0;
m_buffer.clear();
@@ -308,6 +326,8 @@ void LlmClient::sendRound() {
}
}
appendSandboxPreamble(messages);
QJsonObject body;
QJsonObject streamOptions;
streamOptions[QStringLiteral("include_usage")] = true;
@@ -323,15 +343,24 @@ void LlmClient::sendRound() {
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Accept", "text/event-stream");
m_reply = m_manager.post(request, QJsonDocument(body).toJson());
QNetworkReply* reply =
m_manager.post(request, QJsonDocument(body).toJson());
if (!reply) {
fail(QStringLiteral("Failed to start request to LLM server"));
return;
}
m_reply = reply;
connect(m_reply, &QNetworkReply::readyRead, this, [this]() {
if (m_reply) m_buffer.append(m_reply->readAll());
connect(reply, &QNetworkReply::readyRead, this, [this, reply]() {
if (m_reply != reply) return;
m_buffer.append(reply->readAll());
drainBuffer();
});
connect(m_reply, &QNetworkReply::finished, this, [this]() {
QNetworkReply* reply = m_reply;
if (!reply) return;
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
if (m_reply != reply) {
reply->deleteLater();
return;
}
m_reply = nullptr;
const QNetworkReply::NetworkError error = reply->error();
@@ -343,25 +372,37 @@ void LlmClient::sendRound() {
drainBuffer();
if (!m_streaming) return;
if (error == QNetworkReply::NoError)
roundFinished();
else if (error == QNetworkReply::OperationCanceledError)
finishTurn();
else {
const QString message =
serverErrorMessage(responseBody, errorString);
const int reported = contextSizeFromErrorMessage(message);
if (reported > 0 && reported != m_contextSize &&
!m_contextRetryUsed) {
qWarning() << "LlmClient: prompt overflow, server reports"
<< reported << "tokens; retrying round";
setContextSize(reported);
m_contextRetryUsed = true;
sendRound();
if (error == QNetworkReply::NoError) {
if (m_streamDone) {
roundFinished();
return;
}
fail(message);
const QByteArray tail = m_buffer;
m_buffer.clear();
const QString serverMessage = jsonErrorMessage(tail.simplified());
fail(
serverMessage.isEmpty()
? QStringLiteral(
"LLM server closed the stream before "
"completing the response")
: serverMessage);
return;
}
if (error == QNetworkReply::OperationCanceledError) {
finishTurn();
return;
}
const QString message = serverErrorMessage(responseBody, errorString);
const int reported = contextSizeFromErrorMessage(message);
if (reported > 0 && reported != m_contextSize && !m_contextRetryUsed) {
qWarning() << "LlmClient: prompt overflow, server reports"
<< reported << "tokens; retrying round";
setContextSize(reported);
m_contextRetryUsed = true;
sendRound();
return;
}
fail(message);
});
}
@@ -373,12 +414,51 @@ QJsonArray LlmClient::buildContextMessages(
const auto* message = model->at(row);
const auto* generation = message->activeGeneration();
if (!generation) continue;
const bool hasAnyContent = !generation->content().isEmpty() ||
!generation->reasoning().isEmpty() ||
generation->toolCallCount() > 0;
if (message->role() != ChatMessage::Role::User && !hasAnyContent)
continue;
ContextUnit unit;
if (message->role() == ChatMessage::Role::User) {
QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user");
user[QStringLiteral("content")] = generation->content();
const QString text = generation->content();
const QList<Attachment*> attachments = message->attachments();
if (attachments.isEmpty()) {
user[QStringLiteral("content")] = text;
} else {
QJsonArray parts;
if (!text.isEmpty()) {
QJsonObject textPart;
textPart[QStringLiteral("type")] = QStringLiteral("text");
textPart[QStringLiteral("text")] = text;
parts.append(textPart);
}
for (const auto* attachment : attachments) {
if (attachment->kind() != Attachment::Kind::Image) continue;
QFile file(attachment->path());
if (!file.open(QIODevice::ReadOnly)) continue;
const QByteArray base64 = file.readAll().toBase64();
file.close();
if (base64.isEmpty()) continue;
QJsonObject imageUrl;
imageUrl[QStringLiteral("url")] =
QStringLiteral("data:%1;base64,%2")
.arg(attachment->mimeType())
.arg(QString::fromLatin1(base64));
QJsonObject imagePart;
imagePart[QStringLiteral("type")] =
QLatin1String("image_url");
imagePart[QStringLiteral("image_url")] = imageUrl;
parts.append(imagePart);
}
if (parts.isEmpty())
user[QStringLiteral("content")] = text;
else
user[QStringLiteral("content")] = parts;
}
unit.messages.append(user);
} else {
QList<const LlmSegment*> toolSegments;
@@ -455,6 +535,48 @@ QJsonArray LlmClient::buildContextMessages(
return messages;
}
void LlmClient::appendSandboxPreamble(QJsonArray& messages) {
if (!m_sandboxEnabled) return;
ChatSession* active = m_active;
if (!active) return;
const QString workspace = active->workspacePath();
if (workspace.isEmpty()) return;
const auto* model = active->messagesModel();
QStringList files;
QSet<QString> seenPaths;
for (int row = 0; row < model->rowCount(); ++row) {
const auto* message = model->at(row);
if (message->role() != ChatMessage::Role::User) continue;
for (const auto* attachment : message->attachments()) {
if (attachment->kind() != Attachment::Kind::File) continue;
const QString relativePath =
attachment->path().mid(workspace.size() + 1);
if (seenPaths.contains(relativePath)) continue;
seenPaths.insert(relativePath);
files.append(QStringLiteral("- %1 (available as %2)")
.arg(attachment->name(), relativePath));
}
}
if (files.isEmpty()) return;
QJsonObject preamble;
preamble[QStringLiteral("role")] = QStringLiteral("system");
preamble[QStringLiteral("content")] =
QStringLiteral(
"The user attached the following files to this "
"conversation. They are available in your sandbox "
"directory at %1:\n%2\nruncommand executes shell "
"commands inside a bubblewrap sandbox: the rest of the "
"filesystem is read-only, there is no network access, "
"and only the sandbox directory is writable. Use it to "
"inspect, transform, or extract attached files (for "
"example to extract archives or search across many "
"files). Files you create stay inside the sandbox "
"directory. Use the webfetch tool for anything that "
"needs the network.")
.arg(workspace, files.join(QStringLiteral("\n")));
messages.prepend(preamble);
}
void LlmClient::applyToolCallDelta(const QJsonObject& call) {
ChatGeneration* streaming = m_streaming;
if (!streaming) return;
@@ -688,10 +810,12 @@ void LlmClient::stop() {
finishTurn();
return;
}
if (m_reply)
m_reply->abort();
else
endStream();
if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
endStream();
}
void LlmClient::endStream() {
@@ -700,6 +824,11 @@ void LlmClient::endStream() {
ChatSession* session = m_active;
m_streaming = nullptr;
m_active = nullptr;
if (m_reply) {
QNetworkReply* reply = m_reply;
m_reply = nullptr;
reply->abort();
}
generation->setStreaming(false);
m_approvalPending = false;
generation->setApprovalPending(false);
@@ -708,7 +837,7 @@ void LlmClient::endStream() {
segment->status() == LlmSegment::Status::Pending)
segment->finishTool(QStringLiteral("Cancelled"), false);
if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
generation->toolCallCount() == 0) {
generation->toolCallCount() == 0 && !generation->error()) {
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
if (message->generationCount() <= 1) {
if (session) session->removeMessage(message);
@@ -730,7 +859,7 @@ void LlmClient::finishTurn() {
m_pendingClear.clear();
}
if (session) session->persist();
refreshFromServer();
refreshAllStates();
}
void LlmClient::clearOnFinish(ChatSession* session) {
@@ -747,6 +876,7 @@ void LlmClient::sessionRemoved(ChatSession* session) {
void LlmClient::fail(const QString& message) {
qWarning() << "LlmClient:" << message;
if (ChatGeneration* streaming = m_streaming) streaming->setError(message);
finishTurn();
Q_EMIT errorOccurred(message);
}
@@ -766,6 +896,7 @@ void LlmClient::handleLine(const QByteArray& line) {
const QByteArray data = line.mid(5).trimmed();
if (data == "[DONE]") {
m_streamDone = true;
roundFinished();
return;
}
@@ -776,8 +907,12 @@ void LlmClient::handleLine(const QByteArray& line) {
updateTokenUsage(obj);
if (obj.contains("error")) {
const QJsonObject error = obj["error"].toObject();
const QString message = error["message"].toString();
const QJsonValue errorValue = obj["error"];
QString message;
if (errorValue.isObject())
message = errorValue.toObject()["message"].toString();
else if (errorValue.isString())
message = errorValue.toString();
fail(
message.isEmpty() ? QStringLiteral("LLM server returned an error")
: message);
@@ -805,25 +940,36 @@ void LlmClient::handleLine(const QByteArray& line) {
}
}
void LlmClient::refreshFromServer() {
if (m_endpoint.trimmed().isEmpty()) return;
probeContextSize();
refreshModels();
void LlmClient::refreshAllStates() {
if (m_endpoint.trimmed().isEmpty()) {
setServerError(QStringLiteral("LLM endpoint is not configured"));
return;
}
probeContextSize([this]() { refreshModels(); });
}
void LlmClient::refreshModels() {
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
if (!url.isValid() || url.host().isEmpty()) return;
if (!url.isValid() || url.host().isEmpty()) {
setServerError(QStringLiteral("LLM endpoint is not configured"));
return;
}
auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
const QByteArray data = reply->readAll();
reply->deleteLater();
if (error != QNetworkReply::NoError) {
qWarning() << "LlmClient: failed to fetch models:" << error;
const QString serverMessage = jsonErrorMessage(data);
setServerError(
serverMessage.isEmpty()
? QStringLiteral("Cannot reach LLM server: %1")
.arg(errorString)
: serverMessage);
return;
}
const QJsonArray dataArr =
@@ -838,7 +984,12 @@ void LlmClient::refreshModels() {
models.append(id);
}
}
if (models.isEmpty()) return;
if (models.isEmpty()) {
setServerError(QStringLiteral(
"LLM server is running but has no models loaded"));
return;
}
setServerError(QString());
m_availableModels = models;
Q_EMIT availableModelsChanged();
@@ -866,6 +1017,8 @@ void LlmClient::probeContextSize(std::function<void()> done) {
base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty()) {
setServerError(
QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint));
if (done) done();
return;
}
@@ -877,11 +1030,13 @@ void LlmClient::probeContextSize(std::function<void()> done) {
*settled = true;
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
const QByteArray data = reply->readAll();
reply->deleteLater();
int size = 0;
if (error == QNetworkReply::NoError) {
setServerError(QString());
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isArray()) {
for (const auto& value : doc.array()) {
@@ -899,6 +1054,13 @@ void LlmClient::probeContextSize(std::function<void()> done) {
.toObject()["n_ctx"]
.toInt(0);
}
} else {
const QString serverMessage = jsonErrorMessage(data);
setServerError(
serverMessage.isEmpty()
? QStringLiteral("Cannot reach LLM server: %1")
.arg(errorString)
: serverMessage);
}
if (size > 0)
setContextSize(size);
+12 -1
View File
@@ -39,16 +39,20 @@ class LlmClient : public QObject {
}
[[nodiscard]] int contextSize() const { return m_contextSize; }
[[nodiscard]] ToolRegistry* tools() const { return m_tools; }
[[nodiscard]] QString serverError() const { return m_serverError; }
void setEndpoint(const QString& value);
void setModel(const QString& value);
void setTemperature(double value);
void setContextSize(int size);
void probeContextSize(std::function<void()> done = {});
Q_INVOKABLE void refreshFromServer();
void refreshAllStates();
[[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
void setToolsEnabled(bool value) { m_tools->setEnabled(value); }
[[nodiscard]] bool sandboxEnabled() const { return m_sandboxEnabled; }
void setSandboxEnabled(bool value);
[[nodiscard]] QString activeWorkspace() const;
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
@@ -74,6 +78,7 @@ class LlmClient : public QObject {
void toolsEnabledChanged();
void streamingChatIdChanged();
void errorOccurred(const QString& message);
void serverErrorChanged();
void titleSuggested(ZShell::llm::ChatSession* session, const QString& title);
void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon);
@@ -89,6 +94,7 @@ class LlmClient : public QObject {
void sendRound();
QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
void appendSandboxPreamble(QJsonArray& messages);
void applyToolCallDelta(const QJsonObject& call);
void roundFinished();
void executeAllCalls();
@@ -102,6 +108,7 @@ class LlmClient : public QObject {
void updateTokenUsage(const QJsonObject& data);
void setBusy(bool value);
void setStreamingChatId(const QString& id);
void setServerError(const QString& value);
void shortRequest(
const QString& tag,
const QString& systemPrompt,
@@ -109,6 +116,7 @@ class LlmClient : public QObject {
std::function<void(QString result)> onResult);
static QString completionsPath(
const QString& endpoint, const QString& subpath);
static QString jsonErrorMessage(const QByteArray& body);
static QString serverErrorMessage(
const QByteArray& body, const QString& fallback);
@@ -125,6 +133,7 @@ class LlmClient : public QObject {
QString m_endpoint;
QString m_model;
QStringList m_availableModels;
QString m_serverError;
double m_temperature = 0.7;
int m_contextSize = 0;
@@ -139,7 +148,9 @@ class LlmClient : public QObject {
int m_round = 0;
qsizetype m_contentMark = 0;
qsizetype m_reasoningMark = 0;
bool m_sandboxEnabled = true;
bool m_roundDone = false;
bool m_streamDone = false;
bool m_toolPhase = false;
bool m_approvalPending = false;
bool m_contextRetryUsed = false;
+1 -6
View File
@@ -11,12 +11,7 @@ class LlmMarkdown : public QObject {
QML_UNCREATABLE("Blocks are produced by LlmSegment")
public:
enum class Type : int {
Text = 0, // Paragraph, list, quote, table; "text" is markdown source
Heading, // "level" + "text" (markdown source of the content)
Code, // "language" + "code"
Math // "latex" (display math, without the $$ delimiters)
};
enum class Type : int { Text = 0, Heading, Code, Math };
Q_ENUM(Type)
explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {}
+182 -42
View File
@@ -10,9 +10,14 @@
#include <QFile>
#include <QFontDatabase>
#include <QHash>
#include <QMutex>
#include <QMutexLocker>
#include <QPainter>
#include <QPointer>
#include <QThreadPool>
#include <QtQuick/QQuickImageProvider>
namespace ZShell::llm {
namespace {
@@ -71,26 +76,27 @@ const LatinModern& loadLatinModern() {
return fonts;
}
} // namespace
LlmMathText::LlmMathText(QObject* parent)
: QObject(parent)
, m_renderer(
std::make_shared<JKQTMathText>(nullptr, /* useFontsForGUI */ true)) {
void configureLatinModern(JKQTMathText& renderer) {
const LatinModern& fonts = loadLatinModern();
if (fonts.roman)
m_renderer->setFontRomanAndMath(
renderer.setFontRomanAndMath(
QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode);
if (fonts.math) {
m_renderer->setFontMathRoman(
renderer.setFontMathRoman(
QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFallbackFontSymbols(
renderer.setFallbackFontSymbols(
QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
}
}
} // namespace
LlmMathText::LlmMathText(QObject* parent) : QObject(parent) {
loadLatinModern();
}
void LlmMathText::setLatex(const QString& value) {
if (m_latex == value) return;
m_latex = value;
@@ -120,23 +126,88 @@ namespace {
struct MathRender {
bool ok = false;
QImage image;
QUrl url;
QString id;
qreal width = 0;
qreal height = 0;
};
QHash<QString, MathRender>& mathCache() {
static QHash<QString, MathRender> cache;
return cache;
struct MathStore {
QMutex mutex;
QHash<QString, MathRender> entries;
QStringList order;
QHash<QString, QString> idIndex;
QHash<QString, int> idRefs;
qint64 bytes = 0;
quint64 nextId = 1;
};
MathStore& mathStore() {
static MathStore store;
return store;
}
qint64 imageBytes(const QImage& image) {
return image.isNull() ? 0 : static_cast<qint64>(image.sizeInBytes());
}
void retainMathId(const QString& id) {
if (id.isEmpty()) return;
auto& store = mathStore();
QMutexLocker locker(&store.mutex);
++store.idRefs[id];
}
void releaseMathId(const QString& id) {
if (id.isEmpty()) return;
auto& store = mathStore();
QMutexLocker locker(&store.mutex);
auto it = store.idRefs.find(id);
if (it == store.idRefs.end()) return;
if (--it.value() <= 0) store.idRefs.erase(it);
}
} // namespace
namespace {
constexpr qint64 kMaxCacheBytes = 64 * 1024 * 1024;
class MathImageProvider : public QQuickImageProvider {
public:
MathImageProvider() : QQuickImageProvider(QQuickImageProvider::Image) {}
QImage requestImage(
const QString& id, QSize* size, const QSize& requestedSize) override {
Q_UNUSED(requestedSize)
auto& store = mathStore();
QMutexLocker locker(&store.mutex);
const auto keyIt = store.idIndex.constFind(id);
if (keyIt == store.idIndex.constEnd()) return {};
const QString key = keyIt.value();
const auto entryIt = store.entries.constFind(key);
if (entryIt == store.entries.constEnd()) return {};
if (size) *size = entryIt->image.size();
const qsizetype pos = store.order.indexOf(key);
if (pos >= 0) store.order.move(pos, store.order.size() - 1);
return entryIt->image;
}
};
void ensureImageProvider(QObject* owner) {
if (auto* engine = qmlEngine(owner))
if (!engine->imageProvider(QStringLiteral("llmmath")))
engine->addImageProvider(
QStringLiteral("llmmath"), new MathImageProvider());
}
} // namespace
void LlmMathText::reRender() {
ensureImageProvider(this);
++m_requestId;
if (m_latex.trimmed().isEmpty()) {
m_image = QImage();
m_imageUrl = QUrl();
releaseMathId(m_imageId);
m_imageId.clear();
m_width = 0;
m_height = 0;
m_ok = false;
@@ -147,12 +218,30 @@ void LlmMathText::reRender() {
const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() +
QLatin1Char(0x1f) + QString::number(m_fontPointSize) +
QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
if (auto it = mathCache().find(key); it != mathCache().end()) {
m_image = it->image;
m_imageUrl = it->url;
m_width = it->width;
m_height = it->height;
m_ok = it->ok;
bool cacheHit = false;
QString hitId;
qreal hitW = 0;
qreal hitH = 0;
bool hitOk = false;
{
auto& store = mathStore();
QMutexLocker locker(&store.mutex);
const auto it = store.entries.constFind(key);
if (it != store.entries.constEnd()) {
cacheHit = true;
hitId = it->id;
hitW = it->width;
hitH = it->height;
hitOk = it->ok;
}
}
if (cacheHit) {
releaseMathId(m_imageId);
retainMathId(hitId);
m_imageId = hitId;
m_width = hitW;
m_height = hitH;
m_ok = hitOk;
Q_EMIT changed();
return;
}
@@ -164,33 +253,46 @@ void LlmMathText::reRender() {
const QColor color = m_color;
const double pointSize = m_fontPointSize;
const qreal dpr = m_devicePixelRatio;
auto renderer = m_renderer;
QThreadPool::globalInstance()->start(
[this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
[this, id = m_requestId, key, latex, color, pointSize, dpr]() {
JKQTMathText renderer(nullptr, true);
configureLatinModern(renderer);
MathRender render;
renderer->setFontPointSize(pointSize);
renderer->setFontColor(color);
if (renderer->parse(
renderer.setFontPointSize(pointSize);
renderer.setFontColor(color);
if (renderer.parse(
latex,
JKQTMathText::LatexParser,
JKQTMathText::DefaultParseOptions)) {
const QImage image = renderer->drawIntoImage(
/* drawBoxes */ false,
{
QImage scratch(1, 1, QImage::Format_ARGB32);
QPainter painter(&scratch);
const QSizeF size = renderer.getSize(painter);
render.width = size.width() + 2 * kRenderMargin;
render.height = size.height() + 2 * kRenderMargin;
}
QPointer<LlmMathText> sizeGuard(this);
const qreal sizeW = render.width;
const qreal sizeH = render.height;
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[sizeGuard, id, sizeW, sizeH]() {
LlmMathText* self = sizeGuard;
if (!self || id != self->m_requestId) return;
self->m_width = sizeW;
self->m_height = sizeH;
Q_EMIT self->changed();
},
Qt::QueuedConnection);
const QImage image = renderer.drawIntoImage(
false,
QColor(Qt::transparent),
kRenderMargin,
dpr,
kResolutionDpi);
if (!image.isNull()) {
QByteArray png;
{
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
}
render.image = image;
render.url = QUrl(
QStringLiteral("data:image/png;base64,") +
QString::fromLatin1(png.toBase64()));
render.width = image.width() / dpr;
render.height = image.height() / dpr;
render.ok = true;
@@ -208,12 +310,46 @@ void LlmMathText::reRender() {
return;
}
if (render.ok) {
auto& cache = mathCache();
if (cache.size() >= kCacheLimit) cache.clear();
cache.insert(key, render);
auto& store = mathStore();
QMutexLocker locker(&store.mutex);
const bool isNew = !store.entries.contains(key);
if (isNew)
render.id = QString::number(store.nextId++);
else
render.id = store.entries[key].id;
store.idIndex.insert(render.id, key);
if (isNew) {
const qint64 newBytes = imageBytes(render.image);
while (store.order.size() >= kCacheLimit ||
store.bytes + newBytes > kMaxCacheBytes) {
bool evicted = false;
for (int i = 0; i < store.order.size(); ++i) {
const QString oldKey = store.order.at(i);
const auto oldIt =
store.entries.constFind(oldKey);
if (oldIt == store.entries.constEnd()) {
store.order.removeAt(i);
continue;
}
if (store.idRefs.value(oldIt->id, 0) > 0)
continue;
store.order.removeAt(i);
store.bytes -= imageBytes(oldIt->image);
store.idIndex.remove(oldIt->id);
store.entries.remove(oldKey);
evicted = true;
break;
}
if (!evicted) break;
}
store.entries.insert(key, render);
store.order.append(key);
store.bytes += imageBytes(render.image);
}
}
self->m_image = render.image;
self->m_imageUrl = render.url;
releaseMathId(self->m_imageId);
retainMathId(render.id);
self->m_imageId = render.id;
self->m_width = render.width;
self->m_height = render.height;
self->m_ok = render.ok;
@@ -223,4 +359,8 @@ void LlmMathText::reRender() {
});
}
LlmMathText::~LlmMathText() {
releaseMathId(m_imageId);
}
} // namespace ZShell::llm
+4 -13
View File
@@ -1,16 +1,10 @@
#pragma once
#include <QColor>
#include <QImage>
#include <QObject>
#include <QString>
#include <QUrl>
#include <QtQml>
#include <memory>
#include <jkqtmathtext/jkqtmathtext.h>
namespace ZShell::llm {
class LlmMathText : public QObject {
@@ -25,14 +19,14 @@ class LlmMathText : public QObject {
Q_PROPERTY(
qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio
NOTIFY changed)
Q_PROPERTY(QImage image READ image NOTIFY changed)
Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed)
Q_PROPERTY(QString imageId READ imageId NOTIFY changed)
Q_PROPERTY(qreal width READ width NOTIFY changed)
Q_PROPERTY(qreal height READ height NOTIFY changed)
Q_PROPERTY(bool ok READ ok NOTIFY changed)
public:
explicit LlmMathText(QObject* parent = nullptr);
~LlmMathText() override;
[[nodiscard]] QString latex() const { return m_latex; }
void setLatex(const QString& value);
@@ -42,8 +36,7 @@ class LlmMathText : public QObject {
void setFontPointSize(double value);
[[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; }
void setDevicePixelRatio(qreal value);
[[nodiscard]] QImage image() const { return m_image; }
[[nodiscard]] QUrl imageUrl() const { return m_imageUrl; }
[[nodiscard]] QString imageId() const { return m_imageId; }
[[nodiscard]] qreal width() const { return m_width; }
[[nodiscard]] qreal height() const { return m_height; }
[[nodiscard]] bool ok() const { return m_ok; }
@@ -54,13 +47,11 @@ class LlmMathText : public QObject {
private:
void reRender();
std::shared_ptr<JKQTMathText> m_renderer;
QString m_latex;
QColor m_color;
double m_fontPointSize = 12.0;
qreal m_devicePixelRatio = 1.0;
QImage m_image;
QUrl m_imageUrl;
QString m_imageId;
qreal m_width = 0;
qreal m_height = 0;
bool m_ok = false;
+14
View File
@@ -57,6 +57,20 @@ void ChatMessage::setActiveGeneration(int index) {
setActiveInternal(index);
}
void ChatMessage::setAttachments(QList<Attachment*> attachments) {
QList<Attachment*> stale;
for (Attachment* attachment : m_attachments)
if (!attachments.contains(attachment)) stale.append(attachment);
m_attachments.clear();
for (Attachment* attachment : attachments) {
attachment->setParent(this);
m_attachments.append(attachment);
}
Q_EMIT attachmentsChanged();
for (Attachment* attachment : stale)
attachment->deleteLater();
}
void ChatMessage::edit(const QString& newContent) {
if (auto* generation = activeGeneration())
generation->setContent(newContent);
+10
View File
@@ -1,5 +1,6 @@
#pragma once
#include "attachment.hpp"
#include "generation.hpp"
#include <QList>
@@ -27,6 +28,9 @@ class ChatMessage : public QObject {
Q_PROPERTY(
int activeGenerationIndex READ activeGenerationIndex NOTIFY
activeGenerationChanged)
Q_PROPERTY(
QList<ZShell::llm::Attachment*> attachments READ attachments NOTIFY
attachmentsChanged)
public:
enum class Role : int { User = 0, Assistant };
@@ -46,12 +50,16 @@ class ChatMessage : public QObject {
return generation(m_active);
}
[[nodiscard]] int activeGenerationIndex() const { return m_active; }
[[nodiscard]] QList<Attachment*> attachments() const {
return m_attachments;
}
[[nodiscard]] ChatGeneration* generation(int index) const {
if (index < 0 || index >= m_generations.size()) return nullptr;
return m_generations.at(index);
}
Q_INVOKABLE void setActiveGeneration(int index);
void setAttachments(QList<Attachment*> attachments);
Q_INVOKABLE void edit(const QString& newContent);
Q_INVOKABLE void retry();
Q_INVOKABLE void generate();
@@ -63,6 +71,7 @@ class ChatMessage : public QObject {
Q_SIGNALS:
void generationsChanged();
void activeGenerationChanged();
void attachmentsChanged();
private:
void setActiveInternal(int index);
@@ -70,6 +79,7 @@ class ChatMessage : public QObject {
Role m_role;
qint64 m_timestamp;
QList<ChatGeneration*> m_generations;
QList<Attachment*> m_attachments;
int m_active = -1;
};
+1 -1
View File
@@ -11,7 +11,7 @@ namespace ZShell::llm {
namespace {
constexpr int kMarkdownRefreshMs = 150;
} // namespace
}
LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent)
: QObject(parent), m_type(type), m_timestamp(timestamp) {
+174 -6
View File
@@ -1,16 +1,31 @@
#include "session.hpp"
#include "attachment.hpp"
#include "chatstore.hpp"
#include "llmclient.hpp"
#include <QClipboard>
#include <QDebug>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QMimeDatabase>
#include <QMimeData>
#include <QMimeType>
#include <QStandardPaths>
#include <QUrl>
#include <QtGlobal>
namespace ZShell::llm {
namespace {
constexpr qint64 kMaxImageBytes = 20 * 1024 * 1024;
constexpr qint64 kMaxFileBytes = 100 * 1024 * 1024;
QString titleFrom(const QString& content) {
const QString flat = content.simplified();
if (flat.isEmpty()) return QString();
@@ -21,7 +36,7 @@ QString titleFrom(const QString& content) {
} // namespace
ChatSession::ChatSession(const QString& id, QObject* parent)
: QObject(parent), m_id(id) {
: QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) {
m_model = new ChatMessageModel(this, this);
connect(
m_model,
@@ -157,26 +172,179 @@ void ChatSession::startGeneration(ChatMessage* target) {
persist();
}
QString ChatSession::sandboxDirFor(const QString& sessionId) {
const QString state =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation);
return state + QStringLiteral("/zshell/sandboxes/") + sessionId;
}
void ChatSession::ensureWorkspace() {
QDir().mkpath(m_workspace);
}
void ChatSession::setAttachmentError(const QString& value) {
if (m_attachmentError == value) return;
m_attachmentError = value;
Q_EMIT attachmentErrorChanged();
}
QVariantList ChatSession::addAttachments(const QStringList& paths) {
ensureWorkspace();
setAttachmentError(QString());
QVariantList result;
for (const QString& rawPath : paths) {
const QFileInfo info(rawPath);
if (!info.exists() || !info.isFile()) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": file not found or not a file";
setAttachmentError(
QStringLiteral("File not found: %1").arg(info.fileName()));
continue;
}
const QString mimeType = QMimeDatabase().mimeTypeForFile(info).name();
const bool isImage =
mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive);
const qint64 cap = isImage ? kMaxImageBytes : kMaxFileBytes;
if (info.size() > cap) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": too large (" << info.size() << "bytes, cap" << cap
<< ")";
setAttachmentError(
QStringLiteral("File too large: %1").arg(info.fileName()));
continue;
}
QString destination = m_workspace + QLatin1Char('/') + info.fileName();
int suffix = 1;
while (QFile::exists(destination)) {
const QString extension = info.suffix().isEmpty()
? QString()
: QLatin1Char('.') + info.suffix();
destination = m_workspace + QLatin1Char('/') +
info.completeBaseName() +
QStringLiteral("_%1%2").arg(suffix).arg(extension);
++suffix;
}
if (!QFile::copy(info.absoluteFilePath(), destination)) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": copy to" << destination << "failed";
setAttachmentError(
QStringLiteral("Could not copy file: %1").arg(info.fileName()));
continue;
}
auto* attachment = new Attachment(
info.fileName(),
destination,
isImage ? Attachment::Kind::Image : Attachment::Kind::File,
mimeType,
info.size(),
this);
result.append(QVariant::fromValue(attachment));
}
return result;
}
bool ChatSession::clipboardHasAttachment() {
QClipboard* clipboard = QGuiApplication::clipboard();
if (!clipboard) return false;
const QMimeData* data = clipboard->mimeData();
if (!data) return false;
if (data->hasImage()) return true;
for (const QUrl& url : data->urls())
if (url.isLocalFile()) return true;
return false;
}
QVariantList ChatSession::pasteAttachments() {
QClipboard* clipboard = QGuiApplication::clipboard();
const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr;
if (!data) {
qWarning() << "ChatSession: pasteAttachments: no clipboard data";
return {};
}
qInfo() << "ChatSession: pasteAttachments: formats =" << data->formats()
<< "hasImage =" << data->hasImage()
<< "hasUrls =" << data->hasUrls() << "hasText =" << data->hasText();
QStringList paths;
QString tempImagePath;
if (data->hasImage()) {
const QImage image = clipboard->image();
qInfo() << "ChatSession: pasteAttachments: image"
<< (image.isNull() ? "null"
: QString::number(image.width()) + "x" +
QString::number(image.height()));
if (image.isNull()) {
setAttachmentError(
QStringLiteral("Clipboard image could not be decoded"));
return {};
}
tempImagePath =
QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png")
.arg(QDateTime::currentMSecsSinceEpoch());
if (!image.save(tempImagePath)) {
qWarning() << "ChatSession: pasteAttachments: saving image to"
<< tempImagePath << "failed";
setAttachmentError(
QStringLiteral("Could not save the pasted image"));
return {};
}
paths.append(tempImagePath);
} else if (data->hasUrls()) {
for (const QUrl& url : data->urls()) {
qInfo() << "ChatSession: pasteAttachments: url =" << url.toString()
<< "local =" << url.isLocalFile();
if (url.isLocalFile()) paths.append(url.toLocalFile());
}
}
if (paths.isEmpty()) {
if (data->hasImage() || data->hasUrls())
setAttachmentError(QStringLiteral(
"Nothing usable on the clipboard (expected an image "
"or copied files)"));
return {};
}
const QVariantList attachments = addAttachments(paths);
if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath);
qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size()
<< "attachment(s)";
return attachments;
}
void ChatSession::sendMessage(const QString& text) {
sendMessage(text, QVariantList());
}
void ChatSession::sendMessage(
const QString& text, const QVariantList& attachments) {
QList<Attachment*> attachmentList;
for (const QVariant& variant : attachments)
if (auto* attachment =
qobject_cast<Attachment*>(variant.value<QObject*>()))
attachmentList.append(attachment);
const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) return;
if (trimmed.isEmpty() && attachmentList.isEmpty()) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
const qint64 now = QDateTime::currentMSecsSinceEpoch();
appendNewest(ChatMessage::Role::User, trimmed, now);
auto* userMessage = appendNewest(ChatMessage::Role::User, trimmed, now);
if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList);
while (m_model->rowCount() > 200)
m_model->removeMessage(m_model->at(m_model->rowCount() - 1));
if (m_title.isEmpty()) {
m_title = titleFrom(trimmed);
QString titleSource = trimmed;
if (titleSource.isEmpty() && !attachmentList.isEmpty())
titleSource = attachmentList.first()->name();
m_title = titleFrom(titleSource);
Q_EMIT titleChanged();
qInfo() << "ChatSession:" << m_id << "new conversation,"
<< "fallback title" << m_title
<< "- requesting generated title and icon";
if (auto* clientObject = client()) {
clientObject->requestTitle(this, trimmed);
clientObject->requestIcon(this, trimmed);
clientObject->requestTitle(this, titleSource);
clientObject->requestIcon(this, titleSource);
}
}
auto* assistant =
+21
View File
@@ -6,6 +6,8 @@
#include <QDateTime>
#include <QObject>
#include <QString>
#include <QStringList>
#include <QVariantList>
#include <QtQml>
namespace ZShell::llm {
@@ -27,6 +29,10 @@ class ChatSession : public QObject {
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
Q_PROPERTY(
ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT)
Q_PROPERTY(QString workspace READ workspacePath CONSTANT)
Q_PROPERTY(
QString attachmentError READ attachmentError NOTIFY
attachmentErrorChanged)
public:
explicit ChatSession(const QString& id, QObject* parent = nullptr);
@@ -55,6 +61,13 @@ class ChatSession : public QObject {
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
void setLastTokenCount(int value);
[[nodiscard]] QString workspacePath() const { return m_workspace; }
[[nodiscard]] QString attachmentError() const { return m_attachmentError; }
void setAttachmentError(const QString& value);
static QString sandboxDirFor(const QString& sessionId);
void ensureWorkspace();
void setTitle(const QString& value);
void setIcon(const QString& value);
void setUpdatedAt(qint64 value);
@@ -71,7 +84,12 @@ class ChatSession : public QObject {
void removeMessage(ChatMessage* message);
void clearMessages();
Q_INVOKABLE QVariantList addAttachments(const QStringList& paths);
Q_INVOKABLE bool clipboardHasAttachment();
Q_INVOKABLE QVariantList pasteAttachments();
Q_INVOKABLE void sendMessage(const QString& text);
Q_INVOKABLE void sendMessage(
const QString& text, const QVariantList& attachments);
Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target);
Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message);
Q_INVOKABLE void clear();
@@ -83,6 +101,7 @@ class ChatSession : public QObject {
void pinnedChanged();
void messageCountChanged();
void loaded();
void attachmentErrorChanged();
private:
void onModelRowsChanged();
@@ -103,6 +122,8 @@ class ChatSession : public QObject {
bool m_loaded = false;
bool m_clearPending = false;
int m_lastTokenCount = 0;
QString m_workspace;
QString m_attachmentError;
};
} // namespace ZShell::llm
+258
View File
@@ -0,0 +1,258 @@
#include "shelltool.hpp"
#include "llmclient.hpp"
#include "session.hpp"
#include <QDir>
#include <QJsonArray>
#include <QJsonObject>
#include <QProcess>
#include <QTimer>
#include <unistd.h>
namespace ZShell::llm {
namespace {
QJsonObject makeOutput(const QString& text) {
QJsonObject obj;
obj[QStringLiteral("output")] = text;
return obj;
}
QJsonObject makeError(const QString& message) {
QJsonObject obj;
obj[QStringLiteral("error")] = message;
return obj;
}
} // namespace
ShellTool::ShellTool(LlmClient* client, QObject* parent)
: LlmTool(parent), m_client(client) {}
ShellTool::~ShellTool() {
for (auto* job : m_jobs) {
if (job->timer) job->timer->stop();
if (job->process) job->process->kill();
delete job;
}
m_jobs.clear();
}
QString ShellTool::name() const {
return QStringLiteral("runcommand");
}
QString ShellTool::description() const {
return QStringLiteral(
"Run a shell command inside a bubblewrap sandbox that contains "
"the files the user attached to the conversation. The rest of "
"the filesystem is mounted read-only, there is no network "
"access, and only the sandbox directory is writable. The "
"command is executed with /bin/sh and the sandbox directory as "
"its working directory, so relative paths refer to the sandbox. "
"Use it to explore, transform, or extract attached files (for "
"example: ls, find, grep, unzip, tar, head, wc). The combined "
"output is limited; the exit code is always reported. The call "
"only completes when every child process has exited, so do not "
"start long-lived background processes.");
}
QJsonObject ShellTool::parameters() const {
QJsonObject command;
command[QStringLiteral("type")] = QStringLiteral("string");
command[QStringLiteral("description")] =
QStringLiteral("The shell command to execute");
QJsonObject timeout;
timeout[QStringLiteral("type")] = QStringLiteral("integer");
timeout[QStringLiteral("minimum")] = 1;
timeout[QStringLiteral("maximum")] = MaxTimeoutSeconds;
timeout[QStringLiteral("description")] =
QStringLiteral("Timeout in seconds. Defaults to %1.")
.arg(DefaultTimeoutSeconds);
QJsonObject properties;
properties[QStringLiteral("command")] = command;
properties[QStringLiteral("timeout")] = timeout;
QJsonObject schema;
schema[QStringLiteral("type")] = QStringLiteral("object");
schema[QStringLiteral("properties")] = properties;
QJsonArray required;
required.append(QStringLiteral("command"));
schema[QStringLiteral("required")] = required;
return schema;
}
QStringList ShellTool::sandboxArguments(const QString& workspace) {
QStringList args;
args << QLatin1String("--unshare-user") << QLatin1String("--uid")
<< QString::number(::getuid()) << QLatin1String("--gid")
<< QString::number(::getgid()) << QLatin1String("--disable-userns")
<< QLatin1String("--unshare-pid") << QLatin1String("--unshare-net")
<< QLatin1String("--unshare-ipc") << QLatin1String("--unshare-uts")
<< QLatin1String("--hostname") << QLatin1String("zshell-sandbox");
args << QLatin1String("--ro-bind") << QLatin1String("/")
<< QLatin1String("/") << QLatin1String("--tmpfs")
<< QLatin1String("/tmp") << QLatin1String("--tmpfs")
<< QLatin1String("/run") << QLatin1String("--bind") << workspace
<< workspace << QLatin1String("--dev") << QLatin1String("/dev")
<< QLatin1String("--proc") << QLatin1String("/proc");
args << QLatin1String("--chdir") << workspace;
args << QLatin1String("--clearenv") << QLatin1String("--setenv")
<< QLatin1String("PATH")
<< QLatin1String(
"/usr/local/sbin:/usr/local/bin:/usr/sbin:"
"/usr/bin:/sbin:/bin")
<< QLatin1String("--setenv") << QLatin1String("HOME") << workspace
<< QLatin1String("--setenv") << QLatin1String("TMPDIR")
<< QLatin1String("/tmp") << QLatin1String("--setenv")
<< QLatin1String("SHELL") << QLatin1String("/bin/sh");
const QByteArray user = qgetenv("USER");
if (!user.isEmpty())
args << QLatin1String("--setenv") << QLatin1String("USER")
<< QString::fromLocal8Bit(user) << QLatin1String("--setenv")
<< QLatin1String("LOGNAME") << QString::fromLocal8Bit(user);
const QByteArray lang = qgetenv("LANG");
if (!lang.isEmpty())
args << QLatin1String("--setenv") << QLatin1String("LANG")
<< QString::fromLocal8Bit(lang);
args << QLatin1String("--new-session")
<< QLatin1String("--die-with-parent");
return args;
}
void ShellTool::execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
Q_UNUSED(toolCallId);
const QString command = args[QStringLiteral("command")].toString();
if (command.trimmed().isEmpty()) {
done(makeError(QStringLiteral("Missing required argument 'command'")));
return;
}
LlmClient* client = m_client;
if (!client || !client->sandboxEnabled()) {
done(makeError(QStringLiteral(
"The file sandbox is disabled, so no commands can run")));
return;
}
const QString workspace = client->activeWorkspace();
if (workspace.isEmpty()) {
done(makeError(
QStringLiteral("No sandbox available: no chat session is active")));
return;
}
QDir().mkpath(workspace);
const int timeoutSeconds = qBound(
1,
args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds),
MaxTimeoutSeconds);
auto* job = new Job;
job->timeoutSeconds = timeoutSeconds;
job->outputBudgetChars = outputBudgetChars;
job->done = std::move(done);
m_jobs.append(job);
QProcess* process = new QProcess(this);
process->setWorkingDirectory(workspace);
process->setProcessChannelMode(QProcess::MergedChannels);
job->process = process;
QTimer* timer = new QTimer(this);
timer->setSingleShot(true);
connect(timer, &QTimer::timeout, this, [this, job, process]() {
job->timedOut = true;
process->kill();
});
timer->start(timeoutSeconds * 1000);
job->timer = timer;
connect(
process,
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
this,
[this, job](int, QProcess::ExitStatus) { finishJob(job); });
connect(
process,
&QProcess::errorOccurred,
this,
[this, job, process](QProcess::ProcessError error) {
if (job->finished) return;
if (error == QProcess::FailedToStart ||
process->state() == QProcess::NotRunning) {
job->startError = process->errorString();
finishJob(job);
} else {
process->kill();
}
});
QStringList commandArgs = sandboxArguments(workspace);
commandArgs << QStringLiteral("sh") << QStringLiteral("-c") << command;
process->start(QStringLiteral("bwrap"), commandArgs);
}
void ShellTool::cancel() {
for (auto* job : m_jobs) {
if (job->finished || !job->process) continue;
job->cancelled = true;
job->process->kill();
}
}
void ShellTool::finishJob(Job* job) {
if (!job || job->finished) return;
job->finished = true;
if (job->timer) job->timer->stop();
m_jobs.removeAll(job);
QProcess* process = job->process;
const bool ran = process && process->state() != QProcess::NotRunning;
const int exitCode = ran ? process->exitCode() : -1;
QString output;
if (process) output = QString::fromUtf8(process->readAllStandardOutput());
QString note;
if (!job->startError.isEmpty())
note =
QStringLiteral("Command failed to start: %1\n").arg(job->startError);
else if (job->cancelled)
note = QStringLiteral("Command was cancelled.\n");
else if (job->timedOut)
note = QStringLiteral(
"Command timed out after %1 seconds and was killed.\n")
.arg(job->timeoutSeconds);
int budget = inlineBudgetChars();
if (job->outputBudgetChars > 0)
budget = qMin(budget, job->outputBudgetChars);
if (output.size() > budget)
output = output.left(budget) + QStringLiteral(
"\n[output truncated: %1 characters "
"total]")
.arg(output.size());
const QString result =
note + QStringLiteral("exit code: %1\n").arg(exitCode) +
(output.isEmpty() ? QStringLiteral("(no output)") : output);
QJsonObject resultObject;
if (!job->cancelled && !job->timedOut && exitCode == 0)
resultObject = makeOutput(result);
else
resultObject = makeError(result);
std::function<void(const QJsonObject&)> done = std::move(job->done);
if (process) process->deleteLater();
delete job;
if (done) done(resultObject);
}
} // namespace ZShell::llm
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include "tool.hpp"
#include <QList>
#include <QPointer>
#include <QStringList>
class QProcess;
class QTimer;
namespace ZShell::llm {
class LlmClient;
class ShellTool : public LlmTool {
Q_OBJECT
public:
explicit ShellTool(LlmClient* client, QObject* parent = nullptr);
~ShellTool() override;
QString name() const override;
QString description() const override;
QJsonObject parameters() const override;
void execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) override;
void cancel() override;
static constexpr int DefaultTimeoutSeconds = 60;
static constexpr int MaxTimeoutSeconds = 300;
private:
struct Job {
QPointer<QProcess> process;
QPointer<QTimer> timer;
int timeoutSeconds = DefaultTimeoutSeconds;
int outputBudgetChars = 0;
std::function<void(const QJsonObject&)> done;
QString startError;
bool timedOut = false;
bool cancelled = false;
bool finished = false;
};
static QStringList sandboxArguments(const QString& workspace);
void finishJob(Job* job);
QPointer<LlmClient> m_client;
QList<Job*> m_jobs;
};
} // namespace ZShell::llm