new chatlistview component, file attachments, performance & primitive test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user