chore: restructure llm plugin to be more coherent & fix various bugs in llm ui
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 36s
Rust / clippy (pull_request) Successful in 54s

This commit is contained in:
2026-10-18 12:58:38 +02:00
parent fc81be922f
commit 1fe8a3a959
54 changed files with 3022 additions and 2559 deletions
+5 -2
View File
@@ -232,22 +232,25 @@ qml_module(ZShell-llm
URI ZShell.Llm
SOURCES
attachment.hpp attachment.cpp
chat.hpp chat.cpp
chatstore.hpp chatstore.cpp
clipboard.hpp clipboard.cpp
codehighlighter.hpp codehighlighter.cpp
filetool.hpp filetool.cpp
generation.hpp generation.cpp
llmclient.hpp llmclient.cpp
markdownblock.hpp
markdownblock.hpp markdownblock.cpp
markdownparser.hpp markdownparser.cpp
mathtext.hpp mathtext.cpp
message.hpp message.cpp
messagemodel.hpp messagemodel.cpp
prompts.hpp prompts.cpp
segment.hpp segment.cpp
session.hpp session.cpp
shelltool.hpp shelltool.cpp
tool.hpp tool.cpp
turn.hpp turn.cpp
webfetchtool.hpp webfetchtool.cpp
workspace.hpp workspace.cpp
LIBRARIES
Qt::Network
Qt::Sql
-201
View File
@@ -1,201 +0,0 @@
#include "chat.hpp"
#include "filetool.hpp"
#include "llmclient.hpp"
#include "shelltool.hpp"
#include "webfetchtool.hpp"
#include "config/rootnodes.hpp"
#include "config/llm.hpp"
#include <QDebug>
namespace ZShell::llm {
Chat::Chat(QObject* parent)
: QObject(parent)
, m_store(new ChatStore(this))
, m_client(new LlmClient(this)) {
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::ConfigSingleton::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());
});
connect(llm, &config::Llm::modelChanged, this, [this, llm]() {
m_client->setModel(llm->model());
});
connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() {
m_client->setTemperature(llm->temperature());
});
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_hasError || !m_errorMessage.isEmpty())) {
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
Q_EMIT busyChanged();
});
connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect(
m_client,
&LlmClient::availableModelsChanged,
this,
&Chat::availableModelsChanged);
connect(
m_client,
&LlmClient::contextSizeChanged,
this,
&Chat::contextSizeChanged);
connect(
m_client,
&LlmClient::streamingChatIdChanged,
this,
&Chat::streamingChatIdChanged);
connect(
m_client,
&LlmClient::toolsEnabledChanged,
this,
&Chat::toolsEnabledChanged);
connect(
m_client,
&LlmClient::errorOccurred,
this,
[this](const QString& message) {
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,
this,
[this](ChatSession* session) { m_client->sessionRemoved(session); });
connect(
m_client,
&LlmClient::titleSuggested,
this,
[this](ChatSession* session, const QString& title) {
qInfo() << "Chat: applying generated title" << session->id()
<< title << "(was" << session->title() << ")";
session->setTitle(title);
m_store->saveMeta(session);
});
connect(
m_client,
&LlmClient::iconSuggested,
this,
[this](ChatSession* session, const QString& icon) {
qInfo() << "Chat: applying generated icon" << session->id() << icon
<< "(was" << session->icon() << ")";
session->setIcon(icon);
m_store->saveMeta(session);
});
}
bool Chat::busy() const {
return m_client->busy();
}
QString Chat::endpoint() const {
return m_client->endpoint();
}
QString Chat::model() const {
return m_client->model();
}
QStringList Chat::availableModels() const {
return m_client->availableModels();
}
int Chat::contextSize() const {
return m_client->contextSize();
}
bool Chat::toolsEnabled() const {
return m_client->toolsEnabled();
}
void Chat::setToolsEnabled(bool value) {
m_client->setToolsEnabled(value);
if (auto* config = config::ConfigSingleton::instance())
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::ConfigSingleton::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*) {
if (!s_instance) s_instance = new Chat();
return s_instance;
}
void Chat::stop() {
m_client->stop();
}
void Chat::dismissError() {
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
void Chat::refreshModels() {
m_client->refreshModels();
}
void Chat::refreshFromServer() {
m_client->refreshAllStates();
}
void Chat::selectModel(const QString& id) {
if (id.isEmpty()) return;
m_client->setModel(id);
if (auto* config = config::ConfigSingleton::instance())
config->llm()->set_model(id);
}
} // namespace ZShell::llm
-91
View File
@@ -1,91 +0,0 @@
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QtQml>
#include "chatstore.hpp"
class QQmlEngine;
class QJSEngine;
namespace ZShell::llm {
class LlmClient;
class Chat : public QObject {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged)
Q_PROPERTY(QString model READ model NOTIFY modelChanged)
Q_PROPERTY(
QStringList availableModels READ availableModels NOTIFY
availableModelsChanged)
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
Q_PROPERTY(
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
toolsEnabledChanged)
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
streamingChatIdChanged)
public:
explicit Chat(QObject* parent = nullptr);
[[nodiscard]] bool busy() const;
[[nodiscard]] QString endpoint() const;
[[nodiscard]] QString model() const;
[[nodiscard]] QStringList availableModels() const;
[[nodiscard]] int contextSize() const;
[[nodiscard]] bool toolsEnabled() const;
void setToolsEnabled(bool value);
[[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;
Q_INVOKABLE void stop();
Q_INVOKABLE void dismissError();
Q_INVOKABLE void refreshModels();
Q_INVOKABLE void refreshFromServer();
Q_INVOKABLE void selectModel(const QString& id);
static Chat* create(QQmlEngine*, QJSEngine*);
Q_SIGNALS:
void busyChanged();
void endpointChanged();
void modelChanged();
void availableModelsChanged();
void contextSizeChanged();
void toolsEnabledChanged();
void sandboxEnabledChanged();
void errorOccurred(const QString& message);
void errorChanged();
void serverErrorChanged();
void streamingChatIdChanged();
private:
ChatStore* m_store = nullptr;
LlmClient* m_client = nullptr;
bool m_hasError = false;
QString m_errorMessage;
static Chat* s_instance;
};
} // namespace ZShell::llm
+240 -146
View File
@@ -1,6 +1,8 @@
#include "chatstore.hpp"
#include "attachment.hpp"
#include "llmclient.hpp"
#include "markdownblock.hpp"
#include "message.hpp"
#include "segment.hpp"
@@ -11,15 +13,12 @@
#include <QFileInfo>
#include <QPointer>
#include <QStandardPaths>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QThreadPool>
#include <QVector>
#include <QUuid>
#include <algorithm>
namespace ZShell::llm {
namespace {
@@ -76,22 +75,25 @@ struct AttachmentRow {
struct MessageRow {
bool user = false;
qint64 timestamp = 0;
QString content;
QVector<GenerationRow> generations;
QVector<AttachmentRow> attachments;
};
} // namespace
ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
ChatStore::ChatStore(LlmClient* client, QObject* parent)
: QAbstractListModel(parent)
, m_client(client)
, m_connectionName(QUuid::createUuid().toString()) {
openDb();
load();
}
ChatStore::~ChatStore() {
if (m_connectionName.isEmpty()) return;
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
db.close();
QSqlDatabase handle = QSqlDatabase::database(m_connectionName, false);
handle.close();
QSqlDatabase::removeDatabase(m_connectionName);
}
@@ -105,20 +107,20 @@ void ChatStore::openDb() {
QStringLiteral("/zshell/chats.sqlite");
QDir().mkpath(QFileInfo(m_dbPath).absolutePath());
QSqlDatabase db =
QSqlDatabase handle =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
db.setDatabaseName(m_dbPath);
if (!db.open()) {
handle.setDatabaseName(m_dbPath);
if (!handle.open()) {
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
<< db.lastError().text();
<< handle.lastError().text();
return;
}
{
QSqlQuery pragma(db);
QSqlQuery pragma(handle);
pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS sessions (\n"
" id TEXT PRIMARY KEY,\n"
@@ -130,18 +132,23 @@ void ChatStore::openDb() {
")"));
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS messages (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) "
"ON DELETE CASCADE,\n"
" role TEXT NOT NULL,\n"
" content TEXT NOT NULL DEFAULT '',\n"
" timestamp INTEGER NOT NULL\n"
")"));
if (!query.exec(QStringLiteral(
"ALTER TABLE messages ADD COLUMN content TEXT "
"NOT NULL DEFAULT ''"))) {
}
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS generations (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
@@ -152,7 +159,7 @@ void ChatStore::openDb() {
")"));
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS segments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
@@ -170,7 +177,7 @@ void ChatStore::openDb() {
")"));
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS attachments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
@@ -184,7 +191,7 @@ void ChatStore::openDb() {
")"));
}
{
QSqlQuery query(db);
QSqlQuery query(handle);
query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_messages_session "
"ON messages (session_id)"));
@@ -200,93 +207,156 @@ void ChatStore::openDb() {
}
}
int ChatStore::count() const {
int ChatStore::rowCount(const QModelIndex& parent) const {
if (parent.isValid()) return 0;
return static_cast<int>(m_sessions.size());
}
QVariantList ChatStore::values() const {
QVariantList vals;
vals.reserve(m_sessions.size());
for (const auto* session : m_sessions)
vals.append(QVariant::fromValue(session));
return vals;
QVariant ChatStore::data(const QModelIndex& index, int role) const {
if (role != Qt::UserRole || !index.isValid() || index.row() < 0 ||
index.row() >= m_sessions.size())
return {};
return QVariant::fromValue(m_sessions.at(index.row()));
}
ChatSession* ChatStore::at(int index) const {
if (index < 0 || index >= m_sessions.size()) return nullptr;
return m_sessions.at(index);
QHash<int, QByteArray> ChatStore::roleNames() const {
return {{Qt::UserRole, "modelData"}};
}
ChatSession* ChatStore::insert(int index) {
int ChatStore::rowOf(const ChatSession* session) const {
for (int i = 0; i < m_sessions.size(); ++i)
if (m_sessions.at(i) == session) return i;
return -1;
}
bool ChatStore::firstAfter(const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned()) return a->pinned();
return a->updatedAtMs() > b->updatedAtMs();
}
int ChatStore::sortedRow(const ChatSession* session) const {
int row = 0;
while (row < m_sessions.size() && !firstAfter(session, m_sessions.at(row)))
++row;
return row;
}
void ChatStore::insertRow(ChatSession* session) {
const int row = sortedRow(session);
beginInsertRows(QModelIndex(), row, row);
m_sessions.insert(row, session);
endInsertRows();
}
void ChatStore::moveRow(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 ||
to > m_sessions.size() || from == to || to == from + 1)
return;
if (!beginMoveRows(QModelIndex(), from, from, QModelIndex(), to)) return;
m_sessions.move(from, to > from ? to - 1 : to);
endMoveRows();
}
void ChatStore::reposition(ChatSession* session) {
if (m_loading) return;
const int row = rowOf(session);
if (row < 0) return;
const int target = sortedRow(session);
if (target == row) return;
moveRow(row, target);
}
ChatSession* ChatStore::draft() {
if (m_draft) return m_draft;
const qint64 now = QDateTime::currentMSecsSinceEpoch();
const QString id = QUuid::createUuid().toString();
{
QSqlQuery query(db());
query.prepare(
"INSERT INTO sessions (id, title, created_at, updated_at) "
"VALUES (:id, '', :created_at, :updated_at)");
query.bindValue(":id", id);
query.bindValue(":created_at", now);
query.bindValue(":updated_at", now);
if (!query.exec())
qWarning() << "ChatStore: failed to insert session" << id << ":"
<< query.lastError().text();
m_draft = new ChatSession(this, QUuid::createUuid().toString(), this);
m_draft->hydrate(QString(), QString(), now, now, 0, false);
return m_draft;
}
void ChatStore::commitDraft(ChatSession* session) {
if (!session || session->isCommitted()) return;
if (m_draft == session) m_draft = nullptr;
if (!writeSessionRow(session)) return;
session->setCommitted(true);
insertRow(session);
}
bool ChatStore::writeSessionRow(ChatSession* session) {
QSqlQuery query(db());
query.prepare(
"INSERT INTO sessions (id, title, icon, created_at, updated_at, "
"pinned) VALUES (:id, :title, :icon, :created_at, :updated_at, "
":pinned)");
query.bindValue(":id", session->id());
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":created_at", session->createdAtMs());
query.bindValue(":updated_at", session->updatedAtMs());
query.bindValue(":pinned", session->pinned() ? 1 : 0);
if (!query.exec()) {
qWarning() << "ChatStore: failed to insert session" << session->id()
<< ":" << query.lastError().text();
return false;
}
auto* session = new ChatSession(id, this);
session->setMeta(QString(), now, now, 0);
const int pos = index >= 0 && index <= m_sessions.size() ? index : 0;
m_sessions.insert(pos, session);
Q_EMIT countChanged();
Q_EMIT valuesChanged();
return session;
return true;
}
void ChatStore::remove(int index) {
removeSession(at(index));
}
void ChatStore::removeRow(ChatSession* session) {
if (!session) return;
const int row = rowOf(session);
void ChatStore::remove(ChatSession* chat) {
removeSession(chat);
}
void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session)) return;
const QList<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session);
{
QSqlQuery query(db());
query.prepare("DELETE FROM sessions WHERE id = :id");
query.bindValue(":id", session->id());
query.exec();
}
QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively();
m_sessions.removeOne(session);
if (row >= 0) {
beginRemoveRows(QModelIndex(), row, row);
m_sessions.removeAt(row);
endRemoveRows();
}
session->deleteLater();
notify(before);
}
void ChatStore::move(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 ||
to >= m_sessions.size() || from == to)
void ChatStore::remove(ChatSession* chat) {
if (!chat) return;
m_pendingPersists.remove(chat);
m_pendingClears.remove(chat);
if (m_draft == chat) m_draft = nullptr;
if (!chat->isCommitted()) {
Q_EMIT sessionRemoved(chat);
chat->deleteLater();
return;
m_sessions.move(from, to);
Q_EMIT valuesChanged();
}
removeRow(chat);
}
void ChatStore::clear() {
const QList<ChatSession*> sessions = m_sessions;
for (ChatSession* session : sessions)
removeSession(session);
remove(session);
}
ChatSession* ChatStore::sessionById(const QString& id) {
for (auto* session : m_sessions)
if (session->id() == id) return session;
return nullptr;
void ChatStore::requestClear(ChatSession* session) {
if (!session) return;
m_pendingClears.insert(session);
if (session->isLoaded() && m_client->streamingSession() != session)
applyPendingClear(session);
}
void ChatStore::setLlmClient(LlmClient* client) {
m_llmClient = client;
void ChatStore::applyPendingClear(ChatSession* session) {
if (!session || !m_pendingClears.remove(session)) return;
if (!session->isLoaded()) return;
session->clearMessages();
persist(session);
}
void ChatStore::persist(ChatSession* session) {
@@ -297,21 +367,23 @@ void ChatStore::persist(ChatSession* session) {
}
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
if (!saveSession(session)) return;
sortAndNotify();
reposition(session);
}
void ChatStore::saveMeta(ChatSession* session) {
void ChatStore::saveFlags(ChatSession* session) {
if (!session || !m_sessions.contains(session)) return;
QSqlQuery query(db());
query.prepare(
"UPDATE sessions SET title = :title, icon = :icon "
"WHERE id = :id");
"UPDATE sessions SET title = :title, icon = :icon, "
"pinned = :pinned WHERE id = :id");
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":pinned", session->pinned() ? 1 : 0);
query.bindValue(":id", session->id());
if (!query.exec())
qWarning() << "ChatStore: failed to save meta for" << session->id()
qWarning() << "ChatStore: failed to save flags for" << session->id()
<< ":" << query.lastError().text();
reposition(session);
}
bool ChatStore::saveSession(ChatSession* session) {
@@ -326,10 +398,12 @@ bool ChatStore::saveSession(ChatSession* session) {
{
QSqlQuery query(handle);
query.prepare(
"UPDATE sessions SET title = :title, updated_at = :updated_at "
"WHERE id = :id");
"UPDATE sessions SET title = :title, icon = :icon, "
"updated_at = :updated_at, pinned = :pinned WHERE id = :id");
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":updated_at", session->updatedAtMs());
query.bindValue(":pinned", session->pinned() ? 1 : 0);
query.bindValue(":id", session->id());
ok = query.exec();
}
@@ -342,8 +416,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (ok) {
QSqlQuery messageInsert(handle);
ok = messageInsert.prepare(
"INSERT INTO messages (session_id, role, timestamp) "
"VALUES (:id, :role, :timestamp)");
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (:id, :role, :content, :timestamp)");
QSqlQuery generationInsert(handle);
ok = ok &&
generationInsert.prepare(
@@ -371,6 +445,7 @@ bool ChatStore::saveSession(ChatSession* session) {
message->role() == ChatMessage::Role::User
? QStringLiteral("user")
: QStringLiteral("assistant"));
messageInsert.bindValue(":content", sqlText(message->content()));
messageInsert.bindValue(":timestamp", message->timestamp());
if (!messageInsert.exec()) {
ok = false;
@@ -401,16 +476,24 @@ bool ChatStore::saveSession(ChatSession* session) {
segmentInsert.bindValue(":gid", generationId);
segmentInsert.bindValue(
":type", segmentTypeName(segment->type()));
segmentInsert.bindValue(":text", sqlText(segment->text()));
segmentInsert.bindValue(":name", sqlText(segment->name()));
const auto* text =
qobject_cast<const LlmTextSegment*>(segment);
segmentInsert.bindValue(
":tool_call_id", sqlText(segment->toolCallId()));
":text", sqlText(text ? text->text() : QString()));
const auto* call =
qobject_cast<const LlmToolCall*>(segment);
segmentInsert.bindValue(
":arguments", sqlText(segment->arguments()));
":name", sqlText(call ? call->name() : QString()));
segmentInsert.bindValue(
":result", sqlText(segment->result()));
":tool_call_id",
sqlText(call ? call->toolCallId() : QString()));
segmentInsert.bindValue(
":status", static_cast<int>(segment->status()));
":arguments",
sqlText(call ? call->arguments() : QString()));
segmentInsert.bindValue(
":result", sqlText(call ? call->result() : QString()));
segmentInsert.bindValue(
":status", call ? static_cast<int>(call->status()) : 0);
segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
segmentInsert.bindValue(":timestamp", segment->timestamp());
if (!segmentInsert.exec()) {
@@ -448,12 +531,13 @@ bool ChatStore::saveSession(ChatSession* session) {
qWarning() << "ChatStore: saveSession" << id
<< "commit failed, rolling back";
handle.rollback();
ok = false;
qWarning() << "ChatStore: failed to save session" << session->id()
<< ":" << handle.lastError().text();
return false;
}
return ok;
return true;
}
void ChatStore::loadMessagesInto(ChatSession* session) {
if (!session) return;
const QString sessionId = session->id();
@@ -467,15 +551,15 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString();
{
QSqlDatabase db =
QSqlDatabase handle =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path);
if (db.open()) {
QSqlQuery busy(db);
handle.setDatabaseName(path);
if (handle.open()) {
QSqlQuery busy(handle);
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
QSqlQuery query(db);
QSqlQuery query(handle);
query.prepare(
"SELECT id, role, timestamp FROM messages "
"SELECT id, role, content, timestamp FROM messages "
"WHERE session_id = :id ORDER BY rowid DESC");
query.bindValue(":id", sessionId);
if (!query.exec()) {
@@ -487,8 +571,9 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
MessageRow message;
message.user = query.value(1).toString() ==
QLatin1String("user");
message.timestamp = query.value(2).toLongLong();
QSqlQuery generationQuery(db);
message.content = query.value(2).toString();
message.timestamp = query.value(3).toLongLong();
QSqlQuery generationQuery(handle);
generationQuery.prepare(
"SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid "
@@ -501,7 +586,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
generationQuery.value(1).toLongLong();
generation.active =
generationQuery.value(2).toInt() != 0;
QSqlQuery segmentQuery(db);
QSqlQuery segmentQuery(handle);
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, "
"arguments, result, status, elapsed_ms, "
@@ -549,7 +634,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
<< messageId << ":"
<< generationQuery.lastError().text();
}
QSqlQuery attachmentQuery(db);
QSqlQuery attachmentQuery(handle);
attachmentQuery.prepare(
"SELECT name, path, kind, mime, size FROM "
"attachments WHERE message_id = :mid "
@@ -580,10 +665,10 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
rows.append(message);
}
}
db.close();
handle.close();
} else {
qWarning() << "ChatStore: failed to open database for load:"
<< db.lastError().text();
<< handle.lastError().text();
}
}
QSqlDatabase::removeDatabase(connName);
@@ -595,7 +680,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
ChatSession* s = session;
if (!st || !s) return;
auto* model = s->model();
auto* model = s->messagesModel();
if (!model) return;
QList<ChatMessage*> messages;
for (const MessageRow& row : rows) {
@@ -603,6 +688,7 @@ 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 :
@@ -619,27 +705,54 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
}
message->setAttachments(attachments);
}
if (row.user) {
QString content = row.content;
if (content.isEmpty() && !row.generations.isEmpty()) {
for (const SegmentRow& segment :
row.generations.first().segments)
if (segmentTypeFromName(segment.type) ==
LlmSegment::Type::Content) {
content = segment.text;
break;
}
}
if (!content.isEmpty()) message->setContent(content);
messages.append(message);
continue;
}
int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow =
row.generations.at(i);
auto* generation =
message->addGeneration(generationRow.timestamp);
if (!generation) continue;
for (const SegmentRow& segmentRow :
generationRow.segments) {
auto* segment = new LlmSegment(
segmentTypeFromName(segmentRow.type),
segmentRow.timestamp,
generation);
segment->setText(segmentRow.text);
segment->setName(segmentRow.name);
segment->setToolCallId(segmentRow.toolCallId);
segment->appendArguments(segmentRow.arguments);
segment->setResult(segmentRow.result);
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentRow.status));
segment->restore(segmentRow.elapsedMs);
const LlmSegment::Type type =
segmentTypeFromName(segmentRow.type);
LlmSegment* segment = nullptr;
if (type == LlmSegment::Type::ToolCall) {
auto* call = new LlmToolCall(
segmentRow.timestamp, generation);
call->setName(segmentRow.name);
call->setToolCallId(segmentRow.toolCallId);
call->appendArguments(segmentRow.arguments);
call->setResult(segmentRow.result);
call->setStatus(
static_cast<LlmToolCall::Status>(
segmentRow.status));
call->restore(segmentRow.elapsedMs);
segment = call;
} else {
auto* text = new LlmTextSegment(
type, segmentRow.timestamp, generation);
text->setText(segmentRow.text);
text->restore(segmentRow.elapsedMs);
segment = text;
}
generation->addSegment(segment);
}
if (generationRow.active) activeIndex = i;
@@ -658,54 +771,35 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
s->markLoaded();
if (s->takeClearPending()) {
s->clear();
} else if (st->m_pendingPersists.remove(s)) {
st->persist(s);
}
st->applyPendingClear(s);
if (st->m_pendingPersists.remove(s)) st->persist(s);
},
Qt::QueuedConnection);
});
}
void ChatStore::load() {
m_loading = true;
QSqlQuery query(db());
query.exec(
"SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, "
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt "
"FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC");
while (query.next()) {
auto* session = new ChatSession(query.value(0).toString(), this);
session->setMeta(
auto* session = new ChatSession(this, query.value(0).toString(), this);
session->hydrate(
query.value(1).toString(),
query.value(2).toString(),
query.value(3).toLongLong(),
query.value(4).toLongLong(),
query.value(6).toInt());
session->setIcon(query.value(2).toString());
session->setPinned(query.value(5).toInt() != 0);
query.value(6).toInt(),
query.value(5).toInt() != 0);
m_sessions.append(session);
}
sortAndNotify();
}
m_loading = false;
void ChatStore::sortAndNotify() {
const QList<ChatSession*> before = m_sessions;
std::stable_sort(
m_sessions.begin(),
m_sessions.end(),
[](const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned()) return a->pinned() > b->pinned();
return a->updatedAtMs() > b->updatedAtMs();
});
notify(before);
}
void ChatStore::notify(const QList<ChatSession*>& before) {
if (before.size() != m_sessions.size()) Q_EMIT countChanged();
bool same = before.size() == m_sessions.size();
for (int i = 0; same && i < m_sessions.size(); ++i)
if (before.at(i) != m_sessions.at(i)) same = false;
if (!same) Q_EMIT valuesChanged();
beginResetModel();
endResetModel();
}
} // namespace ZShell::llm
+40 -29
View File
@@ -2,65 +2,76 @@
#include "session.hpp"
#include <QObject>
#include <QAbstractListModel>
#include <QHash>
#include <QList>
#include <QSet>
#include <QSqlDatabase>
#include <QString>
#include <QVariantList>
#include <QtQml>
namespace ZShell::llm {
class LlmClient;
class ChatStore : public QObject {
class ChatStore : public QAbstractListModel {
Q_OBJECT
QML_ANONYMOUS
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged)
QML_ELEMENT
QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton")
public:
explicit ChatStore(QObject* parent = nullptr);
explicit ChatStore(LlmClient* client, QObject* parent = nullptr);
~ChatStore() override;
[[nodiscard]] int count() const;
[[nodiscard]] QVariantList values() const;
[[nodiscard]] ChatSession* at(int index) const;
[[nodiscard]] int rowCount(
const QModelIndex& parent = QModelIndex()) const override;
[[nodiscard]] QVariant data(
const QModelIndex& index, int role = Qt::DisplayRole) const override;
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1);
Q_INVOKABLE void remove(int index);
[[nodiscard]] LlmClient* client() const { return m_client; }
[[nodiscard]] int rowOf(const ChatSession* session) const;
Q_INVOKABLE ZShell::llm::ChatSession* draft();
Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat);
Q_INVOKABLE void move(int from, int to);
Q_INVOKABLE void clear();
[[nodiscard]] ChatSession* sessionById(const QString& id);
[[nodiscard]] LlmClient* llmClient() const { return m_llmClient; }
void setLlmClient(LlmClient* client);
void commitDraft(ZShell::llm::ChatSession* session);
void persist(ChatSession* session);
void saveMeta(ChatSession* session);
void loadMessagesInto(ChatSession* session);
void persist(ZShell::llm::ChatSession* session);
void saveFlags(ZShell::llm::ChatSession* session);
void loadMessagesInto(ZShell::llm::ChatSession* session);
void requestClear(ZShell::llm::ChatSession* session);
void applyPendingClear(ZShell::llm::ChatSession* session);
void reposition(ZShell::llm::ChatSession* session);
Q_SIGNALS:
void countChanged();
void valuesChanged();
void sessionRemoved(ZShell::llm::ChatSession* session);
private:
void openDb();
void load();
bool saveSession(ChatSession* session);
void sortAndNotify();
void removeSession(ChatSession* session);
void notify(const QList<ChatSession*>& before);
[[nodiscard]] int sortedRow(const ChatSession* session) const;
[[nodiscard]] static bool firstAfter(
const ChatSession* a, const ChatSession* b);
void insertRow(ChatSession* session);
void moveRow(int from, int to);
void removeRow(ChatSession* session);
[[nodiscard]] bool saveSession(ChatSession* session);
[[nodiscard]] bool writeSessionRow(ChatSession* session);
[[nodiscard]] QSqlDatabase db() const;
LlmClient* m_client;
QList<ChatSession*> m_sessions;
LlmClient* m_llmClient = nullptr;
ChatSession* m_draft = nullptr;
QString m_connectionName;
QString m_dbPath;
QSet<ChatSession*> m_pendingPersists;
[[nodiscard]] QSqlDatabase db() const;
QSet<ChatSession*> m_pendingClears;
bool m_loading = false;
};
} // namespace ZShell::llm
+61
View File
@@ -0,0 +1,61 @@
#include "clipboard.hpp"
#include <QDebug>
#include <QClipboard>
#include <QDateTime>
#include <QDir>
#include <QGuiApplication>
#include <QImage>
#include <QMimeData>
#include <QUrl>
namespace ZShell::llm::clipboard {
bool hasAttachment() {
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;
}
QStringList attachmentPaths(QString& error) {
error.clear();
QClipboard* clipboard = QGuiApplication::clipboard();
const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr;
if (!data) {
qWarning() << "clipboard: no clipboard data";
return {};
}
QStringList paths;
if (data->hasImage()) {
const QImage image = clipboard->image();
if (image.isNull()) {
error = QStringLiteral("Clipboard image could not be decoded");
return {};
}
const QString tempPath =
QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png")
.arg(QDateTime::currentMSecsSinceEpoch());
if (!image.save(tempPath)) {
error = QStringLiteral("Could not save the pasted image");
return {};
}
paths.append(tempPath);
} else if (data->hasUrls()) {
for (const QUrl& url : data->urls())
if (url.isLocalFile()) paths.append(url.toLocalFile());
}
if (paths.isEmpty() && (data->hasImage() || data->hasUrls()))
error = QStringLiteral(
"Nothing usable on the clipboard (expected an image "
"or copied files)");
return paths;
}
} // namespace ZShell::llm::clipboard
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <QString>
#include <QStringList>
namespace ZShell::llm::clipboard {
[[nodiscard]] bool hasAttachment();
[[nodiscard]] QStringList attachmentPaths(QString& error);
} // namespace ZShell::llm::clipboard
+26 -43
View File
@@ -6,23 +6,8 @@
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
FileReadTool::FileReadTool(QObject* parent) : LlmTool(parent) {}
FileReadTool::FileReadTool(ToolRegistry* registry, QObject* parent)
: LlmTool(registry, parent) {}
QString FileReadTool::name() const {
return QStringLiteral("readfile");
@@ -34,8 +19,8 @@ QString FileReadTool::description() const {
"its content. At most a limited number of characters is returned "
"per call; use offset and limit to page through larger files. "
"Binary files are not supported. Full output of webfetch calls "
"is stored under /tmp/zshell-llm/webfetch/ and can be read this "
"way. This tool is read-only.");
"is saved in the sandbox directory and can be read this way. "
"This tool is read-only.");
}
QJsonObject FileReadTool::parameters() const {
@@ -72,19 +57,16 @@ QJsonObject FileReadTool::parameters() const {
}
void FileReadTool::execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
Q_UNUSED(toolCallId);
const ToolContext& context, const QJsonObject& args, ToolDone done) {
const QString path = args[QStringLiteral("path")].toString().trimmed();
if (path.isEmpty()) {
done(makeError(QStringLiteral("Missing required argument 'path'")));
done({false, QStringLiteral("Missing required argument 'path'")});
return;
}
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
int budget = inlineBudgetChars();
if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars);
const int budget = context.outputBudgetChars > 0
? context.outputBudgetChars
: registry()->outputBudgetChars(1);
const int limit =
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
@@ -92,33 +74,33 @@ void FileReadTool::execute(
if (!file.open(QIODevice::ReadOnly)) {
const QFileInfo info(path);
if (info.exists() && info.isDir()) {
done(makeError(QStringLiteral(
"Path is a directory, not a "
"file: %1")
.arg(path)));
done(
{false,
QStringLiteral("Path is a directory, not a file: %1")
.arg(path)});
} else {
done(makeError(QStringLiteral("Cannot open file: %1").arg(path)));
done({false, QStringLiteral("Cannot open file: %1").arg(path)});
}
return;
}
const qint64 fileSize = file.size();
if (fileSize == 0) {
file.close();
done(makeOutput(QString()));
done({true, QString()});
return;
}
if (offset >= fileSize) {
file.close();
done(makeError(QStringLiteral(
"Offset %1 is beyond the end of the "
"file (%2 bytes)")
.arg(offset)
.arg(fileSize)));
done(
{false,
QStringLiteral("Offset %1 is beyond the end of the file (%2 bytes)")
.arg(offset)
.arg(fileSize)});
return;
}
if (!file.seek(offset)) {
file.close();
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path)));
done({false, QStringLiteral("Cannot seek in file: %1").arg(path)});
return;
}
const qint64 wanted = qint64(limit) * 4 + 16;
@@ -126,13 +108,14 @@ void FileReadTool::execute(
file.close();
if (chunk.contains('\0')) {
done(makeError(
QStringLiteral("Binary files are not supported: %1").arg(path)));
done(
{false,
QStringLiteral("Binary files are not supported: %1").arg(path)});
return;
}
const QString content = QString::fromUtf8(chunk);
if (content.size() <= limit) {
done(makeOutput(content));
done({true, content});
return;
}
const QString head = content.left(limit);
@@ -147,7 +130,7 @@ void FileReadTool::execute(
.arg(nextOffset)
.arg(fileSize)
.arg(nextOffset);
done(makeOutput(output));
done({true, output});
}
} // namespace ZShell::llm
+3 -4
View File
@@ -8,16 +8,15 @@ class FileReadTool : public LlmTool {
Q_OBJECT
public:
explicit FileReadTool(QObject* parent = nullptr);
explicit FileReadTool(ToolRegistry* registry, QObject* parent = nullptr);
QString name() const override;
QString description() const override;
QJsonObject parameters() const override;
void execute(
const QString& toolCallId,
const ToolContext& context,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) override;
ToolDone done) override;
};
} // namespace ZShell::llm
+112 -101
View File
@@ -1,37 +1,50 @@
#include "generation.hpp"
#include "llmclient.hpp"
#include "messagemodel.hpp"
#include "message.hpp"
#include "session.hpp"
#include <QDateTime>
namespace ZShell::llm {
ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent)
: QObject(parent), m_timestamp(timestamp) {
ChatGeneration::ChatGeneration(
ChatMessage* message, qint64 timestamp, QObject* parent)
: QObject(parent), m_message(message), m_timestamp(timestamp) {
m_timer.setParent(this);
m_timer.setInterval(500);
m_timer.setTimerType(Qt::CoarseTimer);
connect(&m_timer, &QTimer::timeout, this, [this]() {
bool anyRunning = false;
bool runningText = false;
bool runningTool = false;
for (auto* segment : m_segments) {
if (!segment->running()) continue;
anyRunning = true;
segment->elapsedMsChanged();
Q_EMIT segment->elapsedMsChanged();
if (segment->type() == LlmSegment::Type::ToolCall)
runningTool = true;
else
runningText = true;
}
if (anyRunning) Q_EMIT elapsedMsChanged();
if (!anyRunning && !m_streaming) m_timer.stop();
if (runningText) {
Q_EMIT reasoningElapsedMsChanged();
Q_EMIT contentElapsedMsChanged();
}
if (runningTool) Q_EMIT toolsElapsedMsChanged();
if (!runningText && !runningTool && !m_streaming) m_timer.stop();
});
}
ChatMessage* ChatGeneration::message() const {
return m_message;
}
QString ChatGeneration::content() const {
QStringList parts;
for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content ||
segment->text().isEmpty())
continue;
parts.append(segment->text());
if (segment->type() != LlmSegment::Type::Content) continue;
const auto* text = qobject_cast<const LlmTextSegment*>(segment);
if (!text || text->text().isEmpty()) continue;
parts.append(text->text());
}
return parts.join(QStringLiteral("\n\n"));
}
@@ -39,10 +52,10 @@ QString ChatGeneration::content() const {
QString ChatGeneration::reasoning() const {
QStringList parts;
for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Reasoning ||
segment->text().isEmpty())
continue;
parts.append(segment->text());
if (segment->type() != LlmSegment::Type::Reasoning) continue;
const auto* text = qobject_cast<const LlmTextSegment*>(segment);
if (!text || text->text().isEmpty()) continue;
parts.append(text->text());
}
return parts.join(QStringLiteral("\n\n"));
}
@@ -77,25 +90,27 @@ qint64 ChatGeneration::toolsElapsedMs() const {
return total;
}
QList<LlmToolCall*> ChatGeneration::toolCalls() const {
QList<LlmToolCall*> calls;
for (auto* segment : m_segments)
if (auto* call = qobject_cast<LlmToolCall*>(segment))
calls.append(call);
return calls;
}
int ChatGeneration::toolCallCount() const {
int count = 0;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall) ++count;
return count;
return static_cast<int>(toolCalls().size());
}
bool ChatGeneration::hasRunningTool() const {
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall && segment->running())
return true;
for (const auto* call : toolCalls())
if (call->running()) return true;
return false;
}
bool ChatGeneration::hasPendingTool() const {
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
return true;
for (const auto* call : toolCalls())
if (call->status() == LlmToolCall::Status::Pending) return true;
return false;
}
@@ -103,30 +118,37 @@ void ChatGeneration::setApprovalPending(bool value) {
if (m_approvalPending == value) return;
m_approvalPending = value;
Q_EMIT toolApprovalPendingChanged();
Q_EMIT pendingToolCallsChanged();
}
QVariantList ChatGeneration::pendingToolCalls() const {
QVariantList out;
if (!m_approvalPending) return out;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
out.append(QVariant::fromValue(segment));
for (const auto* call : toolCalls())
if (call->status() == LlmToolCall::Status::Pending)
out.append(QVariant::fromValue(call));
return out;
}
void ChatGeneration::approveTools() {
if (auto* llmClient = client()) llmClient->approveTools();
if (auto* llmClient = client()) llmClient->approveTools(this);
}
void ChatGeneration::denyTools() {
if (auto* llmClient = client()) llmClient->denyTools();
if (auto* llmClient = client()) llmClient->denyTools(this);
}
void ChatGeneration::dismissError() {
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
Q_EMIT errorMessageChanged();
}
LlmClient* ChatGeneration::client() const {
if (auto* message = qobject_cast<ChatMessage*>(parent()))
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent()))
if (auto* session = model->session()) return session->client();
if (m_message && m_message->session())
return m_message->session()->client();
return nullptr;
}
@@ -138,21 +160,19 @@ void ChatGeneration::updateReasoningActive() {
}
void ChatGeneration::setContent(const QString& value) {
LlmSegment* first = nullptr;
LlmTextSegment* first = nullptr;
for (auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content) continue;
auto* text = qobject_cast<LlmTextSegment*>(segment);
if (!text) continue;
if (!first)
first = segment;
first = text;
else
segment->setText(QString());
text->setText(QString());
}
if (!first) {
if (value.isEmpty()) return;
first = new LlmSegment(
LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(),
this);
addSegment(first);
first = openTextSegment(LlmSegment::Type::Content);
}
first->setText(value);
}
@@ -164,7 +184,7 @@ void ChatGeneration::appendContent(const QString& piece) {
segment->running())
segment->close();
}
openContentSegment()->appendText(piece);
openTextSegment(LlmSegment::Type::Content)->appendText(piece);
}
void ChatGeneration::appendReasoning(const QString& piece) {
@@ -173,7 +193,7 @@ void ChatGeneration::appendReasoning(const QString& piece) {
if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->close();
}
openReasoningSegment()->appendText(piece);
openTextSegment(LlmSegment::Type::Reasoning)->appendText(piece);
}
void ChatGeneration::setError(const QString& value) {
@@ -182,13 +202,7 @@ void ChatGeneration::setError(const QString& value) {
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();
Q_EMIT errorMessageChanged();
}
void ChatGeneration::setStreaming(bool value) {
@@ -201,70 +215,67 @@ void ChatGeneration::setStreaming(bool value) {
closeOpenSegments();
m_timer.stop();
}
Q_EMIT elapsedMsChanged();
Q_EMIT reasoningElapsedMsChanged();
Q_EMIT contentElapsedMsChanged();
Q_EMIT toolsElapsedMsChanged();
updateReasoningActive();
}
LlmSegment* ChatGeneration::openContentSegment() {
for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Content && segment->running())
return segment;
auto* segment = new LlmSegment(
LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this);
segment->begin();
addSegment(segment);
return segment;
LlmTextSegment* ChatGeneration::openTextSegment(LlmSegment::Type type) {
for (auto* segment : m_segments) {
if (segment->type() != type) continue;
auto* text = qobject_cast<LlmTextSegment*>(segment);
if (text && text->running()) return text;
}
auto* text =
new LlmTextSegment(type, QDateTime::currentMSecsSinceEpoch(), this);
text->begin();
addSegment(text);
return text;
}
LlmSegment* ChatGeneration::openReasoningSegment() {
for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running())
return segment;
auto* segment = new LlmSegment(
LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this);
segment->begin();
addSegment(segment);
return segment;
}
LlmSegment* ChatGeneration::beginToolCall(
LlmToolCall* ChatGeneration::beginToolCall(
const QString& name, const QString& toolCallId) {
closeOpenSegments();
auto* segment = new LlmSegment(
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
segment->setName(name);
segment->setToolCallId(toolCallId);
segment->setStatus(LlmSegment::Status::Pending);
segment->begin();
addSegment(segment);
Q_EMIT toolStateChanged();
return segment;
auto* call = new LlmToolCall(QDateTime::currentMSecsSinceEpoch(), this);
call->setName(name);
call->setToolCallId(toolCallId);
call->setStatus(LlmToolCall::Status::Pending);
call->begin();
addSegment(call);
Q_EMIT toolCallCountChanged();
return call;
}
void ChatGeneration::addSegment(LlmSegment* segment) {
if (!segment || m_segments.contains(segment)) return;
segment->setParent(this);
connect(segment, &LlmSegment::textChanged, this, [this, segment]() {
if (segment->type() == LlmSegment::Type::Reasoning)
Q_EMIT reasoningChanged();
else if (segment->type() == LlmSegment::Type::Content)
Q_EMIT contentChanged();
updateReasoningActive();
});
connect(segment, &LlmSegment::statusChanged, this, [this]() {
Q_EMIT toolStateChanged();
});
connect(segment, &LlmSegment::resultChanged, this, [this]() {
Q_EMIT toolStateChanged();
});
connect(segment, &LlmSegment::runningChanged, this, [this]() {
Q_EMIT elapsedMsChanged();
Q_EMIT toolStateChanged();
connect(segment, &LlmSegment::runningChanged, this, [this, segment]() {
Q_EMIT toolCallCountChanged();
if (segment->type() == LlmSegment::Type::ToolCall)
Q_EMIT hasRunningToolChanged();
updateReasoningActive();
});
if (auto* text = qobject_cast<LlmTextSegment*>(segment))
connect(text, &LlmTextSegment::textChanged, this, [this, text]() {
if (text->type() == LlmSegment::Type::Reasoning)
Q_EMIT reasoningChanged();
else
Q_EMIT contentChanged();
updateReasoningActive();
});
if (auto* call = qobject_cast<LlmToolCall*>(segment)) {
connect(call, &LlmToolCall::statusChanged, this, [this, call]() {
Q_EMIT hasRunningToolChanged();
Q_EMIT pendingToolCallsChanged();
if (call->status() == LlmToolCall::Status::Pending)
Q_EMIT toolApprovalPendingChanged();
updateReasoningActive();
});
}
m_segments.append(segment);
Q_EMIT segmentsChanged();
Q_EMIT toolCallCountChanged();
}
void ChatGeneration::closeOpenSegments() {
+29 -18
View File
@@ -5,12 +5,13 @@
#include <QList>
#include <QObject>
#include <QString>
#include <QTimer>
#include <QVariantList>
#include <QtQml>
namespace ZShell::llm {
class ChatMessage;
class ChatSession;
class LlmClient;
class ChatGeneration : public QObject {
@@ -18,35 +19,39 @@ class ChatGeneration : public QObject {
QML_ELEMENT
QML_UNCREATABLE("Chat generations are managed by ChatMessage")
Q_PROPERTY(
QString content READ content WRITE setContent NOTIFY contentChanged)
Q_PROPERTY(QString content READ content 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(QString errorMessage READ errorMessage NOTIFY errorMessageChanged)
Q_PROPERTY(
qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
elapsedMsChanged)
reasoningElapsedMsChanged)
Q_PROPERTY(
qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged)
qint64 contentElapsedMs READ contentElapsedMs NOTIFY
contentElapsedMsChanged)
Q_PROPERTY(
qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY toolsElapsedMsChanged)
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
Q_PROPERTY(
bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged)
Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged)
Q_PROPERTY(
bool hasRunningTool READ hasRunningTool NOTIFY hasRunningToolChanged)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(
QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
segmentsChanged)
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY toolCallCountChanged)
Q_PROPERTY(
bool toolApprovalPending READ toolApprovalPending NOTIFY
toolApprovalPendingChanged)
Q_PROPERTY(
QVariantList pendingToolCalls READ pendingToolCalls NOTIFY
toolApprovalPendingChanged)
pendingToolCallsChanged)
public:
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
ChatGeneration(ChatMessage* message, qint64 timestamp, QObject* parent);
[[nodiscard]] ChatMessage* message() const;
[[nodiscard]] QString content() const;
[[nodiscard]] QString reasoning() const;
@@ -59,12 +64,12 @@ class ChatGeneration : public QObject {
[[nodiscard]] bool streaming() const { return m_streaming; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
[[nodiscard]] QList<LlmToolCall*> toolCalls() const;
[[nodiscard]] int toolCallCount() const;
[[nodiscard]] bool hasRunningTool() const;
[[nodiscard]] bool hasPendingTool() const;
[[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; }
void setApprovalPending(bool value);
[[nodiscard]] QVariantList pendingToolCalls() const;
Q_INVOKABLE void approveTools();
@@ -76,10 +81,10 @@ class ChatGeneration : public QObject {
void appendReasoning(const QString& piece);
void setStreaming(bool value);
void setError(const QString& value);
void setApprovalPending(bool value);
[[nodiscard]] LlmSegment* openContentSegment();
[[nodiscard]] LlmSegment* openReasoningSegment();
[[nodiscard]] LlmSegment* beginToolCall(
[[nodiscard]] LlmTextSegment* openTextSegment(LlmSegment::Type type);
[[nodiscard]] LlmToolCall* beginToolCall(
const QString& name, const QString& toolCallId);
void addSegment(LlmSegment* segment);
void closeOpenSegments();
@@ -88,17 +93,23 @@ class ChatGeneration : public QObject {
void contentChanged();
void reasoningChanged();
void errorChanged();
void errorMessageChanged();
void reasoningActiveChanged();
void elapsedMsChanged();
void reasoningElapsedMsChanged();
void contentElapsedMsChanged();
void toolsElapsedMsChanged();
void streamingChanged();
void toolStateChanged();
void hasRunningToolChanged();
void segmentsChanged();
void toolCallCountChanged();
void toolApprovalPendingChanged();
void pendingToolCallsChanged();
private:
void updateReasoningActive();
LlmClient* client() const;
[[nodiscard]] LlmClient* client() const;
ChatMessage* m_message;
QTimer m_timer;
QList<LlmSegment*> m_segments;
bool m_reasoningActive = false;
File diff suppressed because it is too large Load Diff
+53 -73
View File
@@ -1,18 +1,19 @@
#pragma once
#include "segment.hpp"
#include "chatstore.hpp"
#include "tool.hpp"
#include "turn.hpp"
#include <QByteArray>
#include <QJsonArray>
#include <QNetworkAccessManager>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <QTimer>
#include <functional>
#include <memory>
class QJsonObject;
class QNetworkReply;
@@ -21,80 +22,75 @@ namespace ZShell::llm {
class ChatGeneration;
class ChatSession;
class LlmSegment;
class LlmTool;
class LlmClient : public QObject {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString model READ model NOTIFY modelChanged)
Q_PROPERTY(
QStringList availableModels READ availableModels NOTIFY
availableModelsChanged)
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
Q_PROPERTY(
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
toolsEnabledChanged)
Q_PROPERTY(
bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY
sandboxEnabledChanged)
Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
public:
explicit LlmClient(QObject* parent = nullptr);
~LlmClient() override;
[[nodiscard]] QString endpoint() const { return m_endpoint; }
[[nodiscard]] ChatStore* chats() const { return m_store; }
[[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] QString model() const { return m_model; }
[[nodiscard]] double temperature() const { return m_temperature; }
[[nodiscard]] QStringList availableModels() const {
return m_availableModels;
}
[[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 = {});
void refreshAllStates();
[[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] int contextSize() const { return m_tools->contextSize(); }
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
void setToolsEnabled(bool value) { m_tools->setEnabled(value); }
[[nodiscard]] bool sandboxEnabled() const { return m_sandboxEnabled; }
[[nodiscard]] bool sandboxEnabled() const {
return m_tools->sandboxEnabled();
}
[[nodiscard]] QString serverError() const { return m_serverError; }
[[nodiscard]] ChatSession* streamingSession() const {
return m_turn ? m_turn->session() : nullptr;
}
void setToolsEnabled(bool value);
void setSandboxEnabled(bool value);
[[nodiscard]] QString activeWorkspace() const;
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
Q_INVOKABLE void selectModel(const QString& id);
Q_INVOKABLE void stop();
Q_INVOKABLE void refreshModels();
Q_INVOKABLE void refreshFromServer();
void startGeneration(ChatSession* session, ChatGeneration* target);
void stop();
void endStream();
void clearOnFinish(ChatSession* session);
void sessionRemoved(ChatSession* session);
void approveTools();
void denyTools();
void refreshModels();
void approveTools(ZShell::llm::ChatGeneration* generation);
void denyTools(ZShell::llm::ChatGeneration* generation);
void requestTitle(ChatSession* session, const QString& userText);
void requestIcon(ChatSession* session, const QString& userText);
Q_SIGNALS:
void busyChanged();
void endpointChanged();
void modelChanged();
void availableModelsChanged();
void contextSizeChanged();
void toolsEnabledChanged();
void streamingChatIdChanged();
void errorOccurred(const QString& message);
void sandboxEnabledChanged();
void serverErrorChanged();
void titleSuggested(ZShell::llm::ChatSession* session, const QString& title);
void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon);
void generationFailed(
ZShell::llm::ChatSession* session, const QString& message);
private:
struct ToolCallBuilder {
QString id;
QString name;
QString arguments;
QPointer<LlmSegment> segment;
bool seen = false;
};
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,13 +98,17 @@ class LlmClient : public QObject {
void setApprovalPending(bool value);
void finishPendingCalls(const QString& resultText);
void finishTurn();
void endStream();
void fail(const QString& message);
void handleLine(const QByteArray& line);
void drainBuffer();
void updateTokenUsage(const QJsonObject& data);
void setBusy(bool value);
void setStreamingChatId(const QString& id);
void setServerError(const QString& value);
void setModel(const QString& value);
void setContextSize(int size);
void probeContextSize(std::function<void()> done = {});
void refreshAllStates();
void shortRequest(
const QString& tag,
const QString& systemPrompt,
@@ -119,42 +119,22 @@ class LlmClient : public QObject {
static QString jsonErrorMessage(const QByteArray& body);
static QString serverErrorMessage(
const QByteArray& body, const QString& fallback);
[[nodiscard]] ToolContext toolContext() const;
QNetworkAccessManager m_manager;
ChatStore* m_store = nullptr;
ToolRegistry* m_tools = nullptr;
std::unique_ptr<Turn> m_turn;
QNetworkReply* m_reply = nullptr;
QByteArray m_buffer;
QTimer m_refreshTimer;
QPointer<ChatSession> m_active;
QPointer<ChatGeneration> m_streaming;
QPointer<ChatSession> m_pendingClear;
bool m_busy = false;
QString m_streamingChatId;
QString m_endpoint;
QString m_model;
QStringList m_availableModels;
QString m_serverError;
double m_temperature = 0.7;
int m_contextSize = 0;
QJsonArray m_transcript;
QList<ToolCallBuilder> m_callBuilders;
struct ToolCallResult {
QString content;
bool success = false;
};
QList<ToolCallResult> m_callResults;
QString m_finishReason;
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;
int m_pendingCalls = 0;
bool m_busy = false;
bool m_configEnabled = true;
static constexpr int kMaxToolRounds = 12;
};
+23
View File
@@ -0,0 +1,23 @@
#include "markdownblock.hpp"
namespace ZShell::llm {
MarkdownBlock::MarkdownBlock(
Type type,
const QString& id,
const QString& text,
const QString& latex,
const QString& code,
const QString& language,
int level,
QObject* parent)
: QObject(parent)
, m_type(type)
, m_id(id)
, m_text(text)
, m_latex(latex)
, m_code(code)
, m_language(language)
, m_level(level) {}
} // namespace ZShell::llm
+37 -3
View File
@@ -1,20 +1,54 @@
#pragma once
#include <QObject>
#include <QString>
#include <QtQml>
namespace ZShell::llm {
class LlmMarkdown : public QObject {
class MarkdownBlock : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Blocks are produced by LlmSegment")
QML_UNCREATABLE("Blocks are produced by LlmTextSegment")
Q_PROPERTY(Type type READ type CONSTANT)
Q_PROPERTY(QString id READ id CONSTANT)
Q_PROPERTY(QString text READ text CONSTANT)
Q_PROPERTY(QString latex READ latex CONSTANT)
Q_PROPERTY(QString code READ code CONSTANT)
Q_PROPERTY(QString language READ language CONSTANT)
Q_PROPERTY(int level READ level CONSTANT)
public:
enum class Type : int { Text = 0, Heading, Code, Math };
Q_ENUM(Type)
explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {}
MarkdownBlock(
Type type,
const QString& id,
const QString& text,
const QString& latex,
const QString& code,
const QString& language,
int level,
QObject* parent = nullptr);
[[nodiscard]] Type type() const { return m_type; }
[[nodiscard]] QString id() const { return m_id; }
[[nodiscard]] QString text() const { return m_text; }
[[nodiscard]] QString latex() const { return m_latex; }
[[nodiscard]] QString code() const { return m_code; }
[[nodiscard]] QString language() const { return m_language; }
[[nodiscard]] int level() const { return m_level; }
private:
Type m_type;
QString m_id;
QString m_text;
QString m_latex;
QString m_code;
QString m_language;
int m_level = 0;
};
} // namespace ZShell::llm
+16 -25
View File
@@ -1,18 +1,15 @@
#include "markdownparser.hpp"
#include "markdownblock.hpp"
#include <cmark-gfm-extension_api.h>
#include <cmark-gfm.h>
#include <QRegularExpression>
#include <QStringList>
#include <QVariantMap>
namespace ZShell::llm {
QVariantList MarkdownParser::parse(const QString& source) {
QVariantList blocks;
QVector<MarkdownBlockData> MarkdownParser::parse(const QString& source) {
QVector<MarkdownBlockData> blocks;
if (source.trimmed().isEmpty()) return blocks;
const QStringList lines = source.split('\n');
@@ -35,42 +32,36 @@ QVariantList MarkdownParser::parse(const QString& source) {
QStringLiteral("\\$\\$(.+?)\\$\\$"),
QRegularExpression::DotMatchesEverythingOption);
auto makeBlock = [&](LlmMarkdown::Type type) {
QVariantMap block;
block.insert("type", static_cast<int>(type));
block.insert(
"id",
QString::number(static_cast<int>(blocks.size())) +
QLatin1Char(':') + QString::number(static_cast<int>(type)));
return block;
};
auto appendText = [&](const QString& text) {
if (text.trimmed().isEmpty()) return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Text);
block.insert("text", text);
MarkdownBlockData block;
block.type = MarkdownBlock::Type::Text;
block.text = text;
blocks.append(block);
};
auto appendMath = [&](const QString& latex) {
if (latex.trimmed().isEmpty()) return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Math);
block.insert("latex", latex);
MarkdownBlockData block;
block.type = MarkdownBlock::Type::Math;
block.latex = latex;
blocks.append(block);
};
auto appendCode = [&](const QString& language, const QString& code) {
QVariantMap block = makeBlock(LlmMarkdown::Type::Code);
block.insert("language", language);
block.insert("code", code);
MarkdownBlockData block;
block.type = MarkdownBlock::Type::Code;
block.language = language;
block.code = code;
blocks.append(block);
};
auto appendHeading = [&](int level, const QString& text) {
if (text.trimmed().isEmpty()) return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Heading);
block.insert("level", level);
block.insert("text", text);
MarkdownBlockData block;
block.type = MarkdownBlock::Type::Heading;
block.level = level;
block.text = text;
blocks.append(block);
};
+13 -2
View File
@@ -1,13 +1,24 @@
#pragma once
#include "markdownblock.hpp"
#include <QString>
#include <QVariantList>
#include <QVector>
namespace ZShell::llm {
struct MarkdownBlockData {
MarkdownBlock::Type type = MarkdownBlock::Type::Text;
QString text;
QString latex;
QString code;
QString language;
int level = 0;
};
class MarkdownParser {
public:
[[nodiscard]] static QVariantList parse(const QString& source);
[[nodiscard]] static QVector<MarkdownBlockData> parse(const QString& source);
};
} // namespace ZShell::llm
+40 -17
View File
@@ -1,25 +1,36 @@
#include "message.hpp"
#include "messagemodel.hpp"
#include "session.hpp"
namespace ZShell::llm {
namespace {
ChatMessage::ChatMessage(
ChatSession* session, Role role, qint64 timestamp, QObject* parent)
: QObject(parent)
, m_session(session)
, m_role(role)
, m_timestamp(timestamp) {}
ChatSession* sessionOf(const ChatMessage* message) {
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent()))
return model->session();
return nullptr;
QString ChatMessage::content() const {
if (m_role == Role::User) return m_content;
const ChatGeneration* generation = activeGeneration();
return generation ? generation->content() : QString();
}
} // namespace
ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent)
: QObject(parent), m_role(role), m_timestamp(timestamp) {}
void ChatMessage::setContent(const QString& value) {
if (m_role != Role::User || m_content == value) return;
m_content = value;
Q_EMIT contentChanged();
}
ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) {
auto* generation = new ChatGeneration(timestamp, this);
if (m_role != Role::Assistant) return nullptr;
auto* generation = new ChatGeneration(this, timestamp, this);
connect(
generation, &ChatGeneration::contentChanged, this, [this, generation]() {
if (generation == activeGeneration()) Q_EMIT contentChanged();
});
m_generations.append(generation);
if (m_active < 0) m_active = static_cast<int>(m_generations.size() - 1);
Q_EMIT generationsChanged();
@@ -27,7 +38,7 @@ ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) {
}
ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) {
auto* generation = addGeneration(timestamp);
ChatGeneration* generation = addGeneration(timestamp);
setActiveInternal(static_cast<int>(m_generations.size() - 1));
return generation;
}
@@ -44,13 +55,17 @@ void ChatMessage::removeGeneration(ChatGeneration* generation) {
m_active = static_cast<int>(m_generations.size() - 1);
}
Q_EMIT generationsChanged();
if (wasActive) Q_EMIT activeGenerationChanged();
if (wasActive) {
Q_EMIT activeGenerationChanged();
Q_EMIT contentChanged();
}
}
void ChatMessage::setActiveInternal(int index) {
if (index < 0 || index >= m_generations.size() || index == m_active) return;
m_active = index;
Q_EMIT activeGenerationChanged();
Q_EMIT contentChanged();
}
void ChatMessage::setActiveGeneration(int index) {
@@ -72,17 +87,25 @@ void ChatMessage::setAttachments(QList<Attachment*> attachments) {
}
void ChatMessage::edit(const QString& newContent) {
if (auto* generation = activeGeneration())
if (m_role == Role::User) {
setContent(newContent);
} else if (ChatGeneration* generation = activeGeneration()) {
generation->setContent(newContent);
if (auto* session = sessionOf(this)) session->persist();
} else {
return;
}
if (m_session) m_session->persist();
}
void ChatMessage::retry() {
if (auto* session = sessionOf(this)) session->retry(this);
if (m_role != Role::Assistant || !m_session) return;
m_session->retry(this);
}
void ChatMessage::generate() {
if (auto* session = sessionOf(this)) session->continueFrom(this);
if (m_role != Role::Assistant || !m_session) return;
m_session->continueFrom(this);
}
} // namespace ZShell::llm
+16 -2
View File
@@ -10,13 +10,16 @@
namespace ZShell::llm {
class ChatSession;
class ChatMessage : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat messages are created by the Chat singleton")
QML_UNCREATABLE("Chat messages are created by ChatMessageModel")
Q_PROPERTY(Role role READ role CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(QString content READ content NOTIFY contentChanged)
Q_PROPERTY(
int generationCount READ generationCount NOTIFY generationsChanged)
Q_PROPERTY(
@@ -36,10 +39,16 @@ class ChatMessage : public QObject {
enum class Role : int { User = 0, Assistant };
Q_ENUM(Role)
explicit ChatMessage(Role role, qint64 timestamp, QObject* parent = nullptr);
ChatMessage(
ChatSession* session,
Role role,
qint64 timestamp,
QObject* parent = nullptr);
[[nodiscard]] ChatSession* session() const { return m_session; }
[[nodiscard]] Role role() const { return m_role; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] QString content() const;
[[nodiscard]] int generationCount() const {
return static_cast<int>(m_generations.size());
}
@@ -68,7 +77,10 @@ class ChatMessage : public QObject {
ChatGeneration* appendGeneration(qint64 timestamp);
void removeGeneration(ChatGeneration* generation);
void setContent(const QString& value);
Q_SIGNALS:
void contentChanged();
void generationsChanged();
void activeGenerationChanged();
void attachmentsChanged();
@@ -76,8 +88,10 @@ class ChatMessage : public QObject {
private:
void setActiveInternal(int index);
ChatSession* m_session;
Role m_role;
qint64 m_timestamp;
QString m_content;
QList<ChatGeneration*> m_generations;
QList<Attachment*> m_attachments;
int m_active = -1;
+8 -4
View File
@@ -40,14 +40,18 @@ int ChatMessageModel::rowOf(const ChatMessage* message) const {
ChatMessage* ChatMessageModel::createMessage(
ChatMessage::Role role, qint64 timestamp) {
return new ChatMessage(role, timestamp, this);
return new ChatMessage(m_session, role, timestamp, this);
}
ChatMessage* ChatMessageModel::appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp) {
auto* message = new ChatMessage(role, timestamp, this);
auto* generation = message->addGeneration(timestamp);
generation->setContent(content);
auto* message = createMessage(role, timestamp);
if (role == ChatMessage::Role::User) {
message->setContent(content);
} else {
auto* generation = message->addGeneration(timestamp);
generation->setContent(content);
}
beginInsertRows(QModelIndex(), 0, 0);
m_messages.prepend(message);
+68
View File
@@ -0,0 +1,68 @@
#include "prompts.hpp"
namespace ZShell::llm::prompts {
QString sandboxPreamble(const QString& workspace, const QStringList& files) {
return 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")));
}
QString titleSystemPrompt() {
return QStringLiteral(
"Write a short, concise title for a chat conversation starting "
"with the user's message below. At most six words, no quotation "
"marks, no trailing punctuation. Reply with the title only.");
}
QString iconSystemPrompt() {
return QStringLiteral(
"Pick the single icon name from this list that best matches the "
"topic of the user's message below: %1. Reply with only the "
"icon "
"name, exactly as written in the list, and nothing else.")
.arg(iconNames().join(QStringLiteral(", ")));
}
QStringList iconNames() {
return {
"chat", "lightbulb", "code", "description",
"article", "school", "work", "build",
"science", "palette", "music_note", "sports_esports",
"takeout_dining", "flight", "photo_camera", "psychology_alt",
"favorite", "savings", "gamepad", "auto_awesome",
};
}
QString cleanTitle(const QString& raw) {
static const auto isQuote = [](QChar c) {
return c == QLatin1Char('"') || c == QLatin1Char('\'') ||
c == QChar(u'\u201C') || c == QChar(u'\u201D') ||
c == QChar(u'\u2018') || c == QChar(u'\u2019');
};
QString title = raw;
while (title.size() >= 2 && isQuote(title.at(0)) &&
isQuote(title.at(title.size() - 1)))
title = title.mid(1, title.size() - 2).simplified();
while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) ||
title.endsWith(QLatin1Char('!')) ||
title.endsWith(QLatin1Char('?'))))
title.chop(1);
if (title.size() > MaxTitleLength)
title = title.left(MaxTitleLength - 1) + QStringLiteral("…");
return title;
}
} // namespace ZShell::llm::prompts
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <QString>
#include <QStringList>
namespace ZShell::llm::prompts {
[[nodiscard]] QString sandboxPreamble(
const QString& workspace, const QStringList& files);
[[nodiscard]] QString titleSystemPrompt();
[[nodiscard]] QString iconSystemPrompt();
[[nodiscard]] QStringList iconNames();
constexpr int MaxTitleLength = 48;
[[nodiscard]] QString cleanTitle(const QString& raw);
} // namespace ZShell::llm::prompts
+104 -74
View File
@@ -11,16 +11,12 @@ 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) {
m_markdownTimer.setInterval(kMarkdownRefreshMs);
connect(&m_markdownTimer, &QTimer::timeout, this, [this]() {
if (!m_markdownDirty) return;
if (parseMarkdown()) m_markdownDirty = false;
});
}
: QObject(parent), m_type(type), m_timestamp(timestamp) {}
LlmSegment::~LlmSegment() = default;
qint64 LlmSegment::elapsedMs() const {
if (m_startedAt <= 0) return 0;
@@ -45,60 +41,7 @@ void LlmSegment::close() {
Q_EMIT runningChanged();
Q_EMIT elapsedMsChanged();
}
if (m_type == Type::Content && m_markdownDirty) {
m_markdownTimer.stop();
if (parseMarkdown()) m_markdownDirty = false;
}
}
void LlmSegment::appendText(const QString& piece) {
if (piece.isEmpty()) return;
m_text += piece;
Q_EMIT textChanged();
scheduleMarkdown();
}
void LlmSegment::setText(const QString& value) {
if (m_text == value) return;
m_text = value;
Q_EMIT textChanged();
scheduleMarkdown();
}
void LlmSegment::setName(const QString& value) {
if (m_name == value) return;
m_name = value;
Q_EMIT nameChanged();
}
void LlmSegment::setToolCallId(const QString& value) {
if (m_toolCallId == value) return;
m_toolCallId = value;
Q_EMIT toolCallIdChanged();
}
void LlmSegment::appendArguments(const QString& piece) {
if (piece.isEmpty()) return;
m_arguments += piece;
Q_EMIT argumentsChanged();
}
void LlmSegment::setResult(const QString& value) {
if (m_result == value) return;
m_result = value;
Q_EMIT resultChanged();
}
void LlmSegment::setStatus(Status value) {
if (m_status == value) return;
m_status = value;
Q_EMIT statusChanged();
}
void LlmSegment::finishTool(const QString& resultText, bool success) {
setResult(resultText);
setStatus(success ? Status::Success : Status::Error);
close();
closed();
}
void LlmSegment::restore(qint64 elapsedMs) {
@@ -106,32 +49,119 @@ void LlmSegment::restore(qint64 elapsedMs) {
m_endedAt = m_timestamp + qMax<qint64>(0, elapsedMs);
}
void LlmSegment::scheduleMarkdown() {
if (m_type != Type::Content) return;
LlmTextSegment::LlmTextSegment(Type type, qint64 timestamp, QObject* parent)
: LlmSegment(type, timestamp, parent) {
m_markdownTimer.setInterval(kMarkdownRefreshMs);
connect(&m_markdownTimer, &QTimer::timeout, this, [this]() {
if (!m_markdownDirty) return;
if (parseMarkdown()) m_markdownDirty = false;
});
}
void LlmTextSegment::setText(const QString& value) {
if (m_text == value) return;
m_text = value;
Q_EMIT textChanged();
scheduleMarkdown();
}
void LlmTextSegment::appendText(const QString& piece) {
if (piece.isEmpty()) return;
m_text += piece;
Q_EMIT textChanged();
scheduleMarkdown();
}
void LlmTextSegment::closed() {
if (type() != Type::Content) return;
if (m_markdownDirty) {
m_markdownTimer.stop();
if (parseMarkdown()) m_markdownDirty = false;
}
}
void LlmTextSegment::scheduleMarkdown() {
if (type() != Type::Content) return;
m_markdownDirty = true;
if (!m_markdownTimer.isActive()) m_markdownTimer.start();
}
bool LlmSegment::parseMarkdown() {
bool LlmTextSegment::parseMarkdown() {
if (m_parseInFlight) return false;
m_parseInFlight = true;
const QString text = m_text;
QThreadPool::globalInstance()->start([this, text]() {
const QVariantList blocks = MarkdownParser::parse(text);
QPointer<LlmSegment> guard(this);
const QVector<MarkdownBlockData> parsed = MarkdownParser::parse(text);
QPointer<LlmTextSegment> guard(this);
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[guard, blocks]() {
LlmSegment* seg = guard;
if (!seg) return;
seg->m_parseInFlight = false;
seg->m_markdown = blocks;
Q_EMIT seg->markdownChanged();
if (seg->m_markdownDirty) seg->parseMarkdown();
[guard, parsed]() {
LlmTextSegment* segment = guard;
if (!segment) return;
segment->m_parseInFlight = false;
QList<MarkdownBlock*> blocks;
blocks.reserve(static_cast<int>(parsed.size()));
for (int i = 0; i < static_cast<int>(parsed.size()); ++i) {
const MarkdownBlockData& data = parsed.at(i);
const QString id = QStringLiteral("%1:%2").arg(i).arg(
static_cast<int>(data.type));
blocks.append(new MarkdownBlock(
data.type,
id,
data.text,
data.latex,
data.code,
data.language,
data.level,
segment));
}
segment->m_blocks = blocks;
Q_EMIT segment->markdownChanged();
if (segment->m_markdownDirty) segment->parseMarkdown();
},
Qt::QueuedConnection);
});
return true;
}
LlmToolCall::LlmToolCall(qint64 timestamp, QObject* parent)
: LlmSegment(Type::ToolCall, timestamp, parent) {}
void LlmToolCall::setName(const QString& value) {
if (m_name == value) return;
m_name = value;
Q_EMIT nameChanged();
}
void LlmToolCall::setToolCallId(const QString& value) {
if (m_toolCallId == value) return;
m_toolCallId = value;
Q_EMIT toolCallIdChanged();
}
void LlmToolCall::appendArguments(const QString& piece) {
if (piece.isEmpty()) return;
m_arguments += piece;
Q_EMIT argumentsChanged();
}
void LlmToolCall::setResult(const QString& value) {
if (m_result == value) return;
m_result = value;
Q_EMIT resultChanged();
}
void LlmToolCall::setStatus(Status value) {
if (m_status == value) return;
m_status = value;
Q_EMIT statusChanged();
}
void LlmToolCall::finish(const QString& resultText, bool success) {
setResult(resultText);
setStatus(success ? Status::Success : Status::Error);
close();
}
} // namespace ZShell::llm
+90 -46
View File
@@ -1,9 +1,11 @@
#pragma once
#include "markdownblock.hpp"
#include <QList>
#include <QObject>
#include <QTimer>
#include <QString>
#include <QVariantList>
#include <QtQml>
namespace ZShell::llm {
@@ -15,79 +17,121 @@ class LlmSegment : public QObject {
Q_PROPERTY(Type type READ type CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(QString text READ text NOTIFY textChanged)
Q_PROPERTY(QString name READ name NOTIFY nameChanged)
Q_PROPERTY(QString toolCallId READ toolCallId NOTIFY toolCallIdChanged)
Q_PROPERTY(QString arguments READ arguments NOTIFY argumentsChanged)
Q_PROPERTY(QString result READ result NOTIFY resultChanged)
Q_PROPERTY(Status status READ status NOTIFY statusChanged)
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged)
public:
enum class Type : int { Reasoning = 0, ToolCall, Content };
enum class Type : int { Reasoning = 0, Content, ToolCall };
Q_ENUM(Type)
enum class Status : int { None = 0, Running, Success, Error, Pending };
Q_ENUM(Status)
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
~LlmSegment() override;
[[nodiscard]] Type type() const { return m_type; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
[[nodiscard]] QString text() const { return m_text; }
[[nodiscard]] QString name() const { return m_name; }
[[nodiscard]] QString toolCallId() const { return m_toolCallId; }
[[nodiscard]] QString arguments() const { return m_arguments; }
[[nodiscard]] QString result() const { return m_result; }
[[nodiscard]] Status status() const { return m_status; }
[[nodiscard]] bool running() const { return m_running; }
[[nodiscard]] qint64 elapsedMs() const;
[[nodiscard]] QVariantList markdown() const { return m_markdown; }
void begin();
void close();
void appendText(const QString& piece);
void setText(const QString& value);
void setName(const QString& value);
void setToolCallId(const QString& value);
void appendArguments(const QString& piece);
void setResult(const QString& value);
void setStatus(Status value);
void finishTool(const QString& resultText, bool success);
void restore(qint64 elapsedMs);
Q_SIGNALS:
void runningChanged();
void elapsedMsChanged();
protected:
virtual void closed() {}
qint64 m_startedAt = 0;
qint64 m_endedAt = 0;
private:
Type m_type;
qint64 m_timestamp;
bool m_running = false;
};
class LlmTextSegment : public LlmSegment {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Text segments are managed by ChatGeneration")
Q_PROPERTY(QString text READ text NOTIFY textChanged)
Q_PROPERTY(
QList<ZShell::llm::MarkdownBlock*> markdown READ markdown NOTIFY
markdownChanged)
public:
LlmTextSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
[[nodiscard]] QString text() const { return m_text; }
void setText(const QString& value);
void appendText(const QString& piece);
[[nodiscard]] QList<MarkdownBlock*> markdown() const { return m_blocks; }
Q_SIGNALS:
void textChanged();
void markdownChanged();
void nameChanged();
void toolCallIdChanged();
void argumentsChanged();
void resultChanged();
void statusChanged();
void runningChanged();
void elapsedMsChanged();
private:
void scheduleMarkdown();
bool parseMarkdown();
void closed() override;
Type m_type;
qint64 m_timestamp;
QString m_text;
QString m_name;
QString m_toolCallId;
QString m_arguments;
QString m_result;
Status m_status = Status::None;
bool m_running = false;
qint64 m_startedAt = 0;
qint64 m_endedAt = 0;
QVariantList m_markdown;
QList<MarkdownBlock*> m_blocks;
QTimer m_markdownTimer;
bool m_markdownDirty = false;
bool m_parseInFlight = false;
};
class LlmToolCall : public LlmSegment {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Tool calls are managed by ChatGeneration")
Q_PROPERTY(QString name READ name NOTIFY nameChanged)
Q_PROPERTY(QString toolCallId READ toolCallId NOTIFY toolCallIdChanged)
Q_PROPERTY(QString arguments READ arguments NOTIFY argumentsChanged)
Q_PROPERTY(QString result READ result NOTIFY resultChanged)
Q_PROPERTY(Status status READ status NOTIFY statusChanged)
public:
enum class Status : int { None = 0, Running, Success, Error, Pending };
Q_ENUM(Status)
LlmToolCall(qint64 timestamp, QObject* parent = nullptr);
[[nodiscard]] QString name() const { return m_name; }
[[nodiscard]] QString toolCallId() const { return m_toolCallId; }
[[nodiscard]] QString arguments() const { return m_arguments; }
[[nodiscard]] QString result() const { return m_result; }
[[nodiscard]] Status status() const { return m_status; }
void setName(const QString& value);
void setToolCallId(const QString& value);
void appendArguments(const QString& piece);
void setResult(const QString& value);
void setStatus(Status value);
void finish(const QString& resultText, bool success);
Q_SIGNALS:
void nameChanged();
void toolCallIdChanged();
void argumentsChanged();
void resultChanged();
void statusChanged();
private:
QString m_name;
QString m_toolCallId;
QString m_arguments;
QString m_result;
Status m_status = Status::None;
};
} // namespace ZShell::llm
+78 -202
View File
@@ -2,42 +2,29 @@
#include "attachment.hpp"
#include "chatstore.hpp"
#include "clipboard.hpp"
#include "llmclient.hpp"
#include "prompts.hpp"
#include "workspace.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();
if (flat.size() <= 48) return flat;
return flat.left(47) + QStringLiteral("…");
}
constexpr int kMaxStoredMessages = 200;
} // namespace
ChatSession::ChatSession(const QString& id, QObject* parent)
: QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) {
ChatSession::ChatSession(ChatStore* store, const QString& id, QObject* parent)
: QObject(parent), m_store(store), m_id(id) {
m_model = new ChatMessageModel(this, this);
m_workspace = new Workspace(this, this);
connect(
m_model,
&QAbstractListModel::rowsInserted,
@@ -70,18 +57,27 @@ void ChatSession::setIcon(const QString& value) {
if (m_icon == value) return;
m_icon = value;
Q_EMIT iconChanged();
if (m_store) m_store->saveFlags(this);
}
void ChatSession::setUpdatedAt(qint64 value) {
if (m_updatedAt == value) return;
m_updatedAt = value;
Q_EMIT updatedAtChanged();
if (m_store) m_store->reposition(this);
}
void ChatSession::setPinned(bool value) {
if (m_pinned == value) return;
m_pinned = value;
Q_EMIT pinnedChanged();
if (m_store) m_store->saveFlags(this);
}
void ChatSession::setUsageTokens(int value) {
if (m_usageTokens == value) return;
m_usageTokens = value;
Q_EMIT usageTokensChanged();
}
void ChatSession::setCount(int value) {
@@ -90,29 +86,25 @@ void ChatSession::setCount(int value) {
Q_EMIT messageCountChanged();
}
void ChatSession::setMeta(
const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) {
void ChatSession::hydrate(
const QString& title,
const QString& icon,
qint64 createdAt,
qint64 updatedAt,
int messageCount,
bool pinned) {
m_title = title;
m_icon = icon;
m_createdAt = createdAt;
m_updatedAt = updatedAt;
m_messageCount = messageCount;
}
void ChatSession::setLastTokenCount(int value) {
m_lastTokenCount = value;
}
LlmClient* ChatSession::client() const {
if (auto* store = qobject_cast<ChatStore*>(parent()))
return store->llmClient();
return nullptr;
m_pinned = pinned;
}
void ChatSession::ensureLoaded() {
if (m_loadRequested) return;
m_loadRequested = true;
if (auto* store = qobject_cast<ChatStore*>(parent()))
store->loadMessagesInto(this);
if (m_store) m_store->loadMessagesInto(this);
}
ChatMessageModel* ChatSession::messagesModel() {
@@ -126,18 +118,12 @@ void ChatSession::markLoaded() {
Q_EMIT loaded();
}
bool ChatSession::takeClearPending() {
const bool pending = m_clearPending;
m_clearPending = false;
return pending;
}
void ChatSession::adoptMessages(QList<ChatMessage*> messages) {
m_model->loadMessages(std::move(messages));
}
void ChatSession::persist() {
if (auto* store = qobject_cast<ChatStore*>(parent())) store->persist(this);
if (m_store) m_store->persist(this);
}
ChatMessage* ChatSession::appendNewest(
@@ -154,32 +140,27 @@ void ChatSession::clearMessages() {
}
void ChatSession::startGeneration(ChatMessage* target) {
auto* generation = target->activeGeneration();
auto* clientObject = client();
if (generation && clientObject) {
if (isLoaded()) {
clientObject->startGeneration(this, generation);
} else {
connect(
this,
&ChatSession::loaded,
clientObject,
[this, generation, clientObject]() {
clientObject->startGeneration(this, generation);
});
}
ChatGeneration* generation = target->activeGeneration();
LlmClient* clientObject = client();
if (!generation || !clientObject) return;
if (isLoaded()) {
clientObject->startGeneration(this, generation);
return;
}
connect(
this,
&ChatSession::loaded,
clientObject,
[this, generation, clientObject]() {
clientObject->startGeneration(this, generation);
});
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);
LlmClient* ChatSession::client() const {
return m_store ? m_store->client() : nullptr;
}
void ChatSession::setAttachmentError(const QString& value) {
@@ -189,125 +170,34 @@ void ChatSession::setAttachmentError(const QString& value) {
}
QVariantList ChatSession::addAttachments(const QStringList& paths) {
ensureWorkspace();
if (!m_workspace) return {};
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);
QString error;
for (Attachment* attachment : m_workspace->importFiles(paths, error))
result.append(QVariant::fromValue(attachment));
}
if (!error.isEmpty()) setAttachmentError(error);
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;
return clipboard::hasAttachment();
}
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());
}
}
QString error;
const QStringList paths = clipboard::attachmentPaths(error);
if (paths.isEmpty()) {
if (data->hasImage() || data->hasUrls())
setAttachmentError(QStringLiteral(
"Nothing usable on the clipboard (expected an image "
"or copied files)"));
if (!error.isEmpty()) setAttachmentError(error);
return {};
}
const QVariantList attachments = addAttachments(paths);
if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath);
qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size()
<< "attachment(s)";
for (const QString& path : paths)
if (path.startsWith(QDir::tempPath())) QFile::remove(path);
return attachments;
}
@@ -324,55 +214,52 @@ void ChatSession::sendMessage(
attachmentList.append(attachment);
const QString trimmed = text.trimmed();
if (trimmed.isEmpty() && attachmentList.isEmpty()) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
if (client() && client()->busy()) return;
if (!m_committed && m_store) m_store->commitDraft(this);
const qint64 now = QDateTime::currentMSecsSinceEpoch();
auto* userMessage = appendNewest(ChatMessage::Role::User, trimmed, now);
ChatMessage* userMessage =
appendNewest(ChatMessage::Role::User, trimmed, now);
if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList);
while (m_model->rowCount() > 200)
while (m_model->rowCount() > kMaxStoredMessages)
m_model->removeMessage(m_model->at(m_model->rowCount() - 1));
if (m_title.isEmpty()) {
QString titleSource = trimmed;
if (titleSource.isEmpty() && !attachmentList.isEmpty())
titleSource = attachmentList.first()->name();
m_title = titleFrom(titleSource);
m_title = prompts::cleanTitle(titleSource);
Q_EMIT titleChanged();
qInfo() << "ChatSession:" << m_id << "new conversation,"
<< "fallback title" << m_title
<< "- requesting generated title and icon";
if (auto* clientObject = client()) {
persist();
if (LlmClient* clientObject = client()) {
clientObject->requestTitle(this, titleSource);
clientObject->requestIcon(this, titleSource);
}
}
auto* assistant =
ChatMessage* assistant =
appendNewest(ChatMessage::Role::Assistant, QString(), now);
startGeneration(assistant);
}
void ChatSession::retry(ChatMessage* target) {
if (!target || target->role() != ChatMessage::Role::Assistant) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
if (client() && client()->busy()) return;
const int row = m_model->rowOf(target);
if (row < 0) return;
m_model->removeRange(0, row - 1);
if (m_model->rowCount() < 2) return;
const qint64 now = QDateTime::currentMSecsSinceEpoch();
target->appendGeneration(now);
target->appendGeneration(QDateTime::currentMSecsSinceEpoch());
startGeneration(target);
}
void ChatSession::continueFrom(ChatMessage* message) {
if (!message) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
if (client() && client()->busy()) return;
const int row = m_model->rowOf(message);
if (row < 0) return;
@@ -384,26 +271,15 @@ void ChatSession::continueFrom(ChatMessage* message) {
message->appendGeneration(now);
startGeneration(message);
} else {
auto* assistant =
ChatMessage* assistant =
appendNewest(ChatMessage::Role::Assistant, QString(), now);
startGeneration(assistant);
}
}
void ChatSession::clear() {
if (auto* clientObject = client()) {
if (clientObject->busy() && clientObject->streamingSession() == this) {
clientObject->clearOnFinish(this);
clientObject->stop();
return;
}
}
if (!isLoaded()) {
m_clearPending = true;
return;
}
m_model->clear();
persist();
if (!m_store) return;
m_store->requestClear(this);
}
} // namespace ZShell::llm
+26 -22
View File
@@ -1,7 +1,7 @@
#pragma once
#include "messagemodel.hpp"
#include "message.hpp"
#include "messagemodel.hpp"
#include <QDateTime>
#include <QObject>
@@ -12,30 +12,32 @@
namespace ZShell::llm {
class ChatStore;
class LlmClient;
class Workspace;
class ChatSession : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat sessions are managed by Chat.chats")
QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton")
Q_PROPERTY(QString id READ id CONSTANT)
Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged)
Q_PROPERTY(QString icon READ icon NOTIFY iconChanged)
Q_PROPERTY(QString icon READ icon WRITE setIcon NOTIFY iconChanged)
Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT)
Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged)
Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged)
Q_PROPERTY(bool pinned READ pinned WRITE setPinned NOTIFY pinnedChanged)
Q_PROPERTY(bool loaded READ isLoaded NOTIFY loaded)
Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged)
Q_PROPERTY(int usageTokens READ usageTokens NOTIFY usageTokensChanged)
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);
ChatSession(ChatStore* store, const QString& id, QObject* parent = nullptr);
[[nodiscard]] QString id() const { return m_id; }
[[nodiscard]] QString title() const { return m_title; }
@@ -50,34 +52,33 @@ class ChatSession : public QObject {
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
[[nodiscard]] bool pinned() const { return m_pinned; }
[[nodiscard]] int messageCount() const { return m_messageCount; }
[[nodiscard]] int usageTokens() const { return m_usageTokens; }
[[nodiscard]] ChatMessageModel* messagesModel();
void ensureLoaded();
[[nodiscard]] bool isLoaded() const { return m_loaded; }
[[nodiscard]] ChatMessageModel* model() const { return m_model; }
void markLoaded();
[[nodiscard]] bool takeClearPending();
[[nodiscard]] LlmClient* client() const;
[[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();
[[nodiscard]] ChatStore* store() const { return m_store; }
[[nodiscard]] LlmClient* client() const;
[[nodiscard]] Workspace* workspace() const { return m_workspace; }
void setTitle(const QString& value);
void setIcon(const QString& value);
void setUpdatedAt(qint64 value);
void setPinned(bool value);
void setUsageTokens(int value);
void setMeta(
void hydrate(
const QString& title,
const QString& icon,
qint64 createdAt,
qint64 updatedAt,
int messageCount);
int messageCount,
bool pinned);
[[nodiscard]] bool isCommitted() const { return m_committed; }
void setCommitted(bool value) { m_committed = value; }
void adoptMessages(QList<ChatMessage*> messages);
void persist();
@@ -101,6 +102,7 @@ class ChatSession : public QObject {
void pinnedChanged();
void messageCountChanged();
void loaded();
void usageTokensChanged();
void attachmentErrorChanged();
private:
@@ -109,20 +111,22 @@ class ChatSession : public QObject {
ChatMessage::Role role, const QString& content, qint64 timestamp);
void startGeneration(ChatMessage* target);
void setCount(int value);
void setAttachmentError(const QString& value);
ChatStore* m_store;
QString m_id;
bool m_committed = false;
QString m_title;
QString m_icon;
qint64 m_createdAt = 0;
qint64 m_updatedAt = 0;
bool m_pinned = false;
int m_messageCount = 0;
int m_usageTokens = 0;
ChatMessageModel* m_model = nullptr;
Workspace* m_workspace = nullptr;
bool m_loadRequested = false;
bool m_loaded = false;
bool m_clearPending = false;
int m_lastTokenCount = 0;
QString m_workspace;
QString m_attachmentError;
};
+23 -46
View File
@@ -1,8 +1,5 @@
#include "shelltool.hpp"
#include "llmclient.hpp"
#include "session.hpp"
#include <QDir>
#include <QJsonArray>
#include <QJsonObject>
@@ -13,24 +10,8 @@
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(ToolRegistry* registry, QObject* parent)
: LlmTool(registry, parent) {}
ShellTool::~ShellTool() {
for (auto* job : m_jobs) {
@@ -125,27 +106,29 @@ QStringList ShellTool::sandboxArguments(const QString& workspace) {
return args;
}
bool ShellTool::available(const ToolContext& context) const {
return registry()->sandboxEnabled() && !context.workspace.isEmpty();
}
void ShellTool::execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
Q_UNUSED(toolCallId);
const ToolContext& context, const QJsonObject& args, ToolDone done) {
const QString command = args[QStringLiteral("command")].toString();
if (command.trimmed().isEmpty()) {
done(makeError(QStringLiteral("Missing required argument 'command'")));
done({false, 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")));
if (!registry()->sandboxEnabled()) {
done(
{false,
QStringLiteral(
"The file sandbox is disabled, so no commands can run")});
return;
}
const QString workspace = client->activeWorkspace();
const QString workspace = context.workspace;
if (workspace.isEmpty()) {
done(makeError(
QStringLiteral("No sandbox available: no chat session is active")));
done(
{false,
QStringLiteral("No sandbox available: no chat session is active")});
return;
}
QDir().mkpath(workspace);
@@ -156,7 +139,7 @@ void ShellTool::execute(
MaxTimeoutSeconds);
auto* job = new Job;
job->timeoutSeconds = timeoutSeconds;
job->outputBudgetChars = outputBudgetChars;
job->outputBudgetChars = context.outputBudgetChars;
job->done = std::move(done);
m_jobs.append(job);
@@ -230,9 +213,8 @@ void ShellTool::finishJob(Job* job) {
"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);
int budget = job->outputBudgetChars > 0 ? job->outputBudgetChars
: registry()->outputBudgetChars(1);
if (output.size() > budget)
output = output.left(budget) + QStringLiteral(
"\n[output truncated: %1 characters "
@@ -242,17 +224,12 @@ void ShellTool::finishJob(Job* job) {
const QString result =
note + QStringLiteral("exit code: %1\n").arg(exitCode) +
(output.isEmpty() ? QStringLiteral("(no output)") : output);
const bool ok = !job->cancelled && !job->timedOut && exitCode == 0;
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);
ToolDone done = std::move(job->done);
if (process) process->deleteLater();
delete job;
if (done) done(resultObject);
if (done) done({ok, result});
}
} // namespace ZShell::llm
+5 -8
View File
@@ -11,23 +11,21 @@ class QTimer;
namespace ZShell::llm {
class LlmClient;
class ShellTool : public LlmTool {
Q_OBJECT
public:
explicit ShellTool(LlmClient* client, QObject* parent = nullptr);
explicit ShellTool(ToolRegistry* registry, QObject* parent = nullptr);
~ShellTool() override;
QString name() const override;
QString description() const override;
QJsonObject parameters() const override;
bool available(const ToolContext& context) const override;
void execute(
const QString& toolCallId,
const ToolContext& context,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) override;
ToolDone done) override;
void cancel() override;
static constexpr int DefaultTimeoutSeconds = 60;
@@ -39,7 +37,7 @@ class ShellTool : public LlmTool {
QPointer<QTimer> timer;
int timeoutSeconds = DefaultTimeoutSeconds;
int outputBudgetChars = 0;
std::function<void(const QJsonObject&)> done;
ToolDone done;
QString startError;
bool timedOut = false;
bool cancelled = false;
@@ -50,7 +48,6 @@ class ShellTool : public LlmTool {
void finishJob(Job* job);
QPointer<LlmClient> m_client;
QList<Job*> m_jobs;
};
+29 -26
View File
@@ -2,20 +2,14 @@
namespace ZShell::llm {
LlmTool::LlmTool(QObject* parent) : QObject(parent) {}
LlmTool::LlmTool(ToolRegistry* registry, QObject* parent)
: QObject(parent), m_registry(registry) {}
LlmTool::~LlmTool() = default;
void LlmTool::setContextSize(int value) {
if (m_contextSize == value) return;
m_contextSize = value;
}
int LlmTool::inlineBudgetChars() const {
if (m_contextSize <= 0) return DefaultInlineChars;
const int tokens =
qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken);
return tokens * CharsPerToken;
bool LlmTool::available(const ToolContext& context) const {
Q_UNUSED(context);
return true;
}
void LlmTool::cancel() {}
@@ -33,30 +27,22 @@ QJsonObject LlmTool::specification() const {
ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {}
void ToolRegistry::setEnabled(bool value) {
if (m_enabled == value) return;
m_enabled = value;
Q_EMIT enabledChanged();
}
void ToolRegistry::registerTool(LlmTool* tool) {
if (!tool || m_tools.contains(tool)) return;
tool->setParent(this);
tool->setContextSize(m_contextSize);
m_tools.append(tool);
}
LlmTool* ToolRegistry::tool(const QString& name) const {
for (const auto* tool : m_tools)
if (tool->name() == name) return const_cast<LlmTool*>(tool);
for (auto* tool : m_tools)
if (tool->name() == name) return tool;
return nullptr;
}
QJsonArray ToolRegistry::specifications() const {
if (!m_enabled) return {};
QJsonArray ToolRegistry::specifications(const ToolContext& context) const {
QJsonArray specs;
for (const auto* tool : m_tools)
specs.append(tool->specification());
for (auto* tool : m_tools)
if (tool->available(context)) specs.append(tool->specification());
return specs;
}
@@ -68,8 +54,25 @@ void ToolRegistry::cancelAll() {
void ToolRegistry::setContextSize(int value) {
if (m_contextSize == value) return;
m_contextSize = value;
for (auto* tool : m_tools)
tool->setContextSize(value);
}
void ToolRegistry::setEnabled(bool value) {
if (m_enabled == value) return;
m_enabled = value;
}
void ToolRegistry::setSandboxEnabled(bool value) {
if (m_sandbox == value) return;
m_sandbox = value;
}
int ToolRegistry::outputBudgetChars(int callCount) const {
if (m_contextSize <= 0) return DefaultInlineChars;
const int tokens = qBound(
MinBudgetTokens,
m_contextSize / qMax(1, callCount),
DefaultInlineChars / CharsPerToken);
return tokens * CharsPerToken;
}
} // namespace ZShell::llm
+37 -22
View File
@@ -10,61 +10,76 @@
namespace ZShell::llm {
struct ToolResult {
bool ok = false;
QString text;
};
struct ToolContext {
QString toolCallId;
QString workspace;
int outputBudgetChars = 0;
};
using ToolDone = std::function<void(const ToolResult&)>;
class ToolRegistry;
class LlmTool : public QObject {
Q_OBJECT
public:
explicit LlmTool(QObject* parent = nullptr);
explicit LlmTool(ToolRegistry* registry, QObject* parent = nullptr);
~LlmTool() override;
[[nodiscard]] virtual QString name() const = 0;
[[nodiscard]] virtual QString description() const = 0;
[[nodiscard]] virtual QJsonObject parameters() const = 0;
[[nodiscard]] virtual bool available(const ToolContext& context) const;
virtual void execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) = 0;
const ToolContext& context, const QJsonObject& args, ToolDone done) = 0;
virtual void cancel();
[[nodiscard]] QJsonObject specification() const;
[[nodiscard]] int contextSize() const { return m_contextSize; }
void setContextSize(int value);
[[nodiscard]] ToolRegistry* registry() const { return m_registry; }
[[nodiscard]] int inlineBudgetChars() const;
static constexpr int CharsPerToken = 4;
static constexpr int DefaultInlineChars = 64 * 1024;
protected:
int m_contextSize = 0;
private:
ToolRegistry* m_registry;
};
class ToolRegistry : public QObject {
Q_OBJECT
Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged)
public:
explicit ToolRegistry(QObject* parent = nullptr);
[[nodiscard]] bool enabled() const { return m_enabled; }
void setEnabled(bool value);
void registerTool(LlmTool* tool);
[[nodiscard]] LlmTool* tool(const QString& name) const;
[[nodiscard]] QJsonArray specifications() const;
[[nodiscard]] QList<LlmTool*> tools() const { return m_tools; }
[[nodiscard]] QJsonArray specifications(const ToolContext& context) const;
void cancelAll();
[[nodiscard]] bool enabled() const { return m_enabled; }
void setEnabled(bool value);
[[nodiscard]] bool sandboxEnabled() const { return m_sandbox; }
void setSandboxEnabled(bool value);
[[nodiscard]] int contextSize() const { return m_contextSize; }
void setContextSize(int value);
Q_SIGNALS:
void enabledChanged();
[[nodiscard]] int outputBudgetChars(int callCount) const;
static constexpr int CharsPerToken = 4;
static constexpr int DefaultInlineChars = 64 * 1024;
static constexpr int MinBudgetTokens = 512;
private:
bool m_enabled = true;
bool m_sandbox = true;
int m_contextSize = 0;
QList<LlmTool*> m_tools;
};
+391
View File
@@ -0,0 +1,391 @@
#include "turn.hpp"
#include "attachment.hpp"
#include "message.hpp"
#include "messagemodel.hpp"
#include "prompts.hpp"
#include "session.hpp"
#include "workspace.hpp"
#include <QFile>
#include <QSet>
namespace ZShell::llm {
namespace {
constexpr int kMinContextTokens = 512;
constexpr int kToolResultStubChars = 512;
constexpr double kPromptEstimateDiscount = 10.0 / 12.0;
qsizetype jsonValueChars(const QJsonValue& value) {
switch (value.type()) {
case QJsonValue::String:
return value.toString().size();
case QJsonValue::Array: {
qsizetype total = 0;
for (const QJsonValue& item : value.toArray())
total += jsonValueChars(item);
return total;
}
case QJsonValue::Object: {
qsizetype total = 0;
const QJsonObject obj = value.toObject();
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it)
total += jsonValueChars(it.value());
return total;
}
default:
return 2;
}
}
int estimateMessageTokens(const QJsonObject& message) {
return int(jsonValueChars(message) / ToolRegistry::CharsPerToken) + 4;
}
int estimateArrayTokens(const QJsonArray& messages) {
int total = 0;
for (const QJsonValue& value : messages)
total += estimateMessageTokens(value.toObject());
return total;
}
int completionReserve(int contextSize) {
return qBound(2048, contextSize / 8, 8192);
}
void shrinkUnitToBudget(QList<QJsonObject>& unit, int budgetTokens) {
static const QStringList kTextKeys = {
QStringLiteral("content"), QStringLiteral("reasoning_content")};
for (int pass = 0; pass < 8; ++pass) {
int tokens = 0;
for (const QJsonObject& message : unit)
tokens += estimateMessageTokens(message);
if (tokens <= budgetTokens) return;
qsizetype bestMessage = -1;
QString bestKey;
qsizetype bestLength = 0;
for (qsizetype i = 0; i < unit.size(); ++i) {
const QJsonObject message = unit.at(i);
for (const QString& key : kTextKeys) {
const QJsonValue value = message.value(key);
if (value.isString() && value.toString().size() > bestLength) {
bestMessage = i;
bestKey = key;
bestLength = value.toString().size();
}
}
}
if (bestMessage < 0) return;
QString text = unit.at(bestMessage).value(bestKey).toString();
text.truncate(qMax<qsizetype>(32, text.size() * 3 / 4));
const QString marker = QStringLiteral("\n[…]");
if (!text.endsWith(marker)) text += marker;
unit[bestMessage][bestKey] = text;
}
}
} // namespace
Turn::Turn(
ChatSession* session,
ChatGeneration* generation,
const ToolRegistry& registry)
: m_session(session), m_generation(generation), m_registry(registry) {}
int Turn::targetRow() const {
if (!m_session) return -1;
const ChatMessage* message = m_generation ? m_generation->message()
: nullptr;
return message ? m_session->messagesModel()->rowOf(message) : -1;
}
int Turn::transcriptTokens() const {
return estimateArrayTokens(m_transcript);
}
int Turn::promptBudgetTokens() const {
const int contextSize = m_registry.contextSize();
if (contextSize <= 0) return 0;
return int(
(contextSize - completionReserve(contextSize)) *
kPromptEstimateDiscount);
}
int Turn::batchSize() const {
int size = 0;
for (const Call& call : m_calls)
if (call.seen) ++size;
return size;
}
int Turn::outputBudgetChars() const {
const int batch = qMax(1, batchSize());
const int promptLimit = promptBudgetTokens();
if (promptLimit <= 0) return m_registry.outputBudgetChars(batch);
const int available = promptLimit - transcriptTokens();
const int tokens = qMax(available / batch, ToolRegistry::MinBudgetTokens);
return tokens * ToolRegistry::CharsPerToken;
}
void Turn::resetRound() {
roundDone = false;
streamDone = false;
toolPhase = false;
pendingCalls = 0;
finishReason.clear();
m_calls.clear();
m_results.clear();
}
void Turn::appendToTranscript(const QJsonObject& message) {
m_transcript.append(message);
}
QJsonObject Turn::roundAssistantMessage() {
QJsonObject assistant;
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
const QString content = m_generation->content();
const QString reasoning = m_generation->reasoning();
if (content.size() > m_contentMark) {
assistant[QStringLiteral("content")] = content.mid(m_contentMark);
m_contentMark = content.size();
}
if (reasoning.size() > m_reasoningMark) {
assistant[QStringLiteral("reasoning_content")] =
reasoning.mid(m_reasoningMark);
m_reasoningMark = reasoning.size();
}
QJsonArray calls;
for (const Call& call : m_calls) {
if (!call.seen) continue;
QJsonObject function;
function[QStringLiteral("name")] = call.name;
function[QStringLiteral("arguments")] = call.arguments;
QJsonObject entry;
entry[QStringLiteral("id")] = call.id;
entry[QStringLiteral("type")] = QStringLiteral("function");
entry[QStringLiteral("function")] = function;
calls.append(entry);
}
assistant[QStringLiteral("tool_calls")] = calls;
return assistant;
}
QJsonArray Turn::toolMessages() const {
QJsonArray messages;
for (int i = 0; i < m_calls.size(); ++i) {
const Call& call = m_calls.at(i);
if (!call.seen) continue;
QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = call.id;
toolMessage[QStringLiteral("content")] = m_results.at(i).text;
messages.append(toolMessage);
}
return messages;
}
bool Turn::roundHasCalls() const {
for (const Call& call : m_calls)
if (call.seen) return true;
return false;
}
void Turn::appendSandboxPreamble(QJsonArray& messages) const {
if (!m_registry.sandboxEnabled()) return;
const QString workspace = m_session->workspace()->path();
if (workspace.isEmpty()) return;
const auto* model = m_session->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")] =
prompts::sandboxPreamble(workspace, files);
messages.prepend(preamble);
}
QJsonArray Turn::contextMessages() const {
const auto* model = m_session->messagesModel();
const int stopBeforeRow = targetRow();
QList<QList<QJsonObject>> units;
for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) {
const auto* message = model->at(row);
if (message->role() == ChatMessage::Role::User) {
if (message->content().isEmpty() &&
message->attachments().isEmpty())
continue;
QList<QJsonObject> unit;
QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user");
const QString text = message->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.append(user);
units.append(unit);
continue;
}
const ChatGeneration* generation = message->activeGeneration();
if (!generation) continue;
const QList<LlmToolCall*> calls = generation->toolCalls();
const bool hasAnyContent = !generation->content().isEmpty() ||
!generation->reasoning().isEmpty() ||
!calls.isEmpty();
if (!hasAnyContent) continue;
QJsonObject assistant;
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
if (!calls.isEmpty() || !generation->content().isEmpty())
assistant[QStringLiteral("content")] = generation->content();
if (!generation->reasoning().isEmpty())
assistant[QStringLiteral("reasoning_content")] =
generation->reasoning();
if (!calls.isEmpty()) {
QJsonArray callArray;
for (const auto* call : calls) {
QJsonObject function;
function[QStringLiteral("name")] = call->name();
function[QStringLiteral("arguments")] = call->arguments();
QJsonObject entry;
entry[QStringLiteral("id")] = call->toolCallId();
entry[QStringLiteral("type")] = QStringLiteral("function");
entry[QStringLiteral("function")] = function;
callArray.append(entry);
}
assistant[QStringLiteral("tool_calls")] = callArray;
}
QList<QJsonObject> unit;
unit.append(assistant);
for (const auto* call : calls) {
QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = call->toolCallId();
toolMessage[QStringLiteral("content")] = call->result();
unit.append(toolMessage);
}
units.append(unit);
}
if (m_registry.contextSize() > 0) {
const int reserve = transcriptTokens();
int budget = m_registry.contextSize() -
completionReserve(m_registry.contextSize()) - reserve;
budget = qMax(budget, kMinContextTokens);
int total = 0;
for (const QList<QJsonObject>& unit : units)
for (const QJsonObject& message : unit)
total += estimateMessageTokens(message);
while (units.size() > 1 && total > budget) {
for (const QJsonObject& message : units.first())
total -= estimateMessageTokens(message);
units.removeFirst();
}
while (units.size() > 1 &&
units.first().first()[QStringLiteral("role")].toString() ==
QLatin1String("assistant")) {
for (const QJsonObject& message : units.first())
total -= estimateMessageTokens(message);
units.removeFirst();
}
if (units.size() == 1) {
QList<QJsonObject> single = units.first();
shrinkUnitToBudget(single, budget);
units[0] = single;
}
}
QJsonArray messages;
for (const QList<QJsonObject>& unit : units)
for (const QJsonObject& message : unit)
messages.append(message);
for (const QJsonValue& value : m_transcript)
messages.append(value);
if (m_registry.contextSize() > 0) {
const int promptLimit = promptBudgetTokens();
while (estimateArrayTokens(messages) > promptLimit) {
bool stubbed = false;
for (qsizetype i = 0; i < messages.size(); ++i) {
QJsonObject message = messages.at(i).toObject();
if (message.value("role").toString() != QLatin1String("tool"))
continue;
const QString content = message.value("content").toString();
if (content.size() <= kToolResultStubChars) continue;
QString stub = content.left(kToolResultStubChars);
stub += QStringLiteral(
"\n[truncated to fit the context window; was "
"%1 characters]")
.arg(content.size());
message["content"] = stub;
messages[i] = message;
stubbed = true;
break;
}
if (!stubbed) break;
}
}
appendSandboxPreamble(messages);
return messages;
}
} // namespace ZShell::llm
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include "generation.hpp"
#include "segment.hpp"
#include "tool.hpp"
#include <QJsonArray>
#include <QJsonObject>
#include <QPointer>
#include <QString>
namespace ZShell::llm {
class ChatSession;
class Turn {
public:
Turn(
ChatSession* session,
ChatGeneration* generation,
const ToolRegistry& registry);
[[nodiscard]] ChatSession* session() const { return m_session; }
[[nodiscard]] ChatGeneration* generation() const { return m_generation; }
[[nodiscard]] const ToolRegistry& registry() const { return m_registry; }
[[nodiscard]] QJsonArray contextMessages() const;
[[nodiscard]] int transcriptTokens() const;
[[nodiscard]] int promptBudgetTokens() const;
[[nodiscard]] int targetRow() const;
void resetRound();
[[nodiscard]] QJsonObject roundAssistantMessage();
[[nodiscard]] QJsonArray toolMessages() const;
[[nodiscard]] bool roundHasCalls() const;
struct Call {
QString id;
QString name;
QString arguments;
QPointer<LlmToolCall> segment;
bool seen = false;
};
QList<Call>& calls() { return m_calls; }
[[nodiscard]] const QList<Call>& calls() const { return m_calls; }
QList<ToolResult>& results() { return m_results; }
[[nodiscard]] const QList<ToolResult>& results() const { return m_results; }
void appendToTranscript(const QJsonObject& message);
[[nodiscard]] const QJsonArray& transcript() const { return m_transcript; }
[[nodiscard]] int batchSize() const;
[[nodiscard]] int outputBudgetChars() const;
int round = 0;
int pendingCalls = 0;
bool roundDone = false;
bool streamDone = false;
bool toolPhase = false;
bool approvalPending = false;
bool contextRetryUsed = false;
QString finishReason;
private:
void appendSandboxPreamble(QJsonArray& messages) const;
ChatSession* m_session;
ChatGeneration* m_generation;
const ToolRegistry& m_registry;
QJsonArray m_transcript;
QList<Call> m_calls;
QList<ToolResult> m_results;
qsizetype m_contentMark = 0;
qsizetype m_reasoningMark = 0;
};
} // namespace ZShell::llm
+37 -42
View File
@@ -10,30 +10,16 @@
namespace ZShell::llm {
const QString WebFetchTool::StoragePath =
QStringLiteral("/tmp/zshell-llm/webfetch");
namespace {
const char* kUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
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
WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {}
WebFetchTool::WebFetchTool(ToolRegistry* registry, QObject* parent)
: LlmTool(registry, parent) {}
WebFetchTool::~WebFetchTool() {
for (auto* job : m_jobs) {
@@ -52,9 +38,9 @@ QString WebFetchTool::description() const {
"Fetch content from an HTTP or HTTPS URL and return it as plain "
"text or raw HTML. HTML pages are reduced to their visible text "
"by default. Only a limited amount of content is returned inline; "
"the full output of every call is saved under "
"/tmp/zshell-llm/webfetch/, where it can be paged through with "
"the readfile tool. This tool is read-only.");
"the full output of every call is saved in the sandbox directory "
"of the conversation, where it can be paged through with the "
"readfile tool. This tool is read-only.");
}
QJsonObject WebFetchTool::parameters() const {
@@ -94,7 +80,7 @@ QJsonObject WebFetchTool::parameters() const {
return schema;
}
void WebFetchTool::completeJob(Job* job, QJsonObject result) {
void WebFetchTool::completeJob(Job* job, ToolResult result) {
QMetaObject::invokeMethod(
this,
[this, job, result = std::move(result)]() mutable {
@@ -110,19 +96,23 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) {
}
void WebFetchTool::execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
const ToolContext& context, const QJsonObject& args, ToolDone done) {
auto* job = new Job;
job->done = std::move(done);
job->toolCallId = toolCallId;
job->budgetChars = outputBudgetChars;
job->toolCallId = context.toolCallId;
job->budgetChars = context.outputBudgetChars > 0
? context.outputBudgetChars
: registry()->outputBudgetChars(1);
job->storagePath =
context.workspace.isEmpty()
? QDir(QDir::tempPath())
.filePath(QStringLiteral("zshell-llm/webfetch"))
: context.workspace + QStringLiteral("/webfetch");
m_jobs.append(job);
auto fail = [this, job](const QString& message) {
job->timer->stop();
completeJob(job, makeError(message));
completeJob(job, {false, message});
};
const QString urlText = args[QStringLiteral("url")].toString().trimmed();
@@ -194,23 +184,27 @@ void WebFetchTool::execute(
if (job->tooLarge) {
completeJob(
job,
makeError(QStringLiteral(
"Response too large (exceeds the 5 MB "
"limit")));
ToolResult{
false,
QStringLiteral(
"Response too large (exceeds the 5 MB "
"limit")});
return;
}
if (error != QNetworkReply::NoError) {
completeJob(
job,
makeError(
QStringLiteral("Request failed: %1").arg(errorString)));
ToolResult{
false,
QStringLiteral("Request failed: %1").arg(errorString)});
return;
}
if (status < 200 || status >= 300) {
completeJob(
job,
makeError(
QStringLiteral("Server returned status %1").arg(status)));
ToolResult{
false,
QStringLiteral("Server returned status %1").arg(status)});
return;
}
@@ -220,9 +214,10 @@ void WebFetchTool::execute(
if (mime.startsWith(QLatin1String("image/"))) {
completeJob(
job,
makeError(
ToolResult{
false,
QStringLiteral("Unsupported fetched image content type: %1")
.arg(mime)));
.arg(mime)});
return;
}
const bool textual =
@@ -236,9 +231,10 @@ void WebFetchTool::execute(
if (!textual) {
completeJob(
job,
makeError(
ToolResult{
false,
QStringLiteral("Unsupported fetched file content type: %1")
.arg(mime)));
.arg(mime)});
return;
}
@@ -249,9 +245,8 @@ void WebFetchTool::execute(
const QString savedPath = saveToFile(*job, content, mime);
const int budget = job->budgetChars;
QString output = content;
int budget = inlineBudgetChars();
if (job->budgetChars > 0) budget = qMin(budget, job->budgetChars);
if (content.size() > budget) {
output = content.left(budget);
const QString note =
@@ -269,13 +264,13 @@ void WebFetchTool::execute(
". The remaining content is not "
"available.");
}
completeJob(job, makeOutput(output));
completeJob(job, ToolResult{true, output});
});
}
QString WebFetchTool::saveToFile(
const Job& job, const QString& content, const QString& mime) const {
QDir dir(StoragePath);
QDir dir(job.storagePath);
if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) return QString();
QString fileName;
+6 -7
View File
@@ -20,19 +20,17 @@ class WebFetchTool : public LlmTool {
static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
static constexpr int DefaultTimeoutSeconds = 30;
static constexpr int MaxTimeoutSeconds = 120;
static const QString StoragePath;
explicit WebFetchTool(QObject* parent = nullptr);
explicit WebFetchTool(ToolRegistry* registry, QObject* parent = nullptr);
~WebFetchTool() override;
QString name() const override;
QString description() const override;
QJsonObject parameters() const override;
void execute(
const QString& toolCallId,
const ToolContext& context,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) override;
ToolDone done) override;
void cancel() override;
static QString extractTextFromHtml(const QString& html);
@@ -46,11 +44,12 @@ class WebFetchTool : public LlmTool {
bool tooLarge = false;
QString format;
QString toolCallId;
QString storagePath;
int budgetChars = 0;
std::function<void(const QJsonObject& result)> done;
ToolDone done;
};
void completeJob(Job* job, QJsonObject result);
void completeJob(Job* job, ToolResult result);
QString saveToFile(
const Job& job, const QString& content, const QString& mime) const;
+89
View File
@@ -0,0 +1,89 @@
#include "workspace.hpp"
#include "session.hpp"
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMimeDatabase>
#include <QStandardPaths>
namespace ZShell::llm {
Workspace::Workspace(ChatSession* session, QObject* parent)
: QObject(parent), m_session(session) {
m_path = pathFor(session->id());
}
QString Workspace::pathFor(const QString& sessionId) {
const QString state =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation);
return state + QStringLiteral("/zshell/sandboxes/") + sessionId;
}
bool Workspace::ensure() {
return QDir().mkpath(m_path);
}
QList<Attachment*> Workspace::importFiles(
const QStringList& paths, QString& error) {
error.clear();
if (!ensure()) {
error = QStringLiteral("Could not create the sandbox directory");
return {};
}
QMimeDatabase mimeDatabase;
QList<Attachment*> result;
for (const QString& rawPath : paths) {
const QFileInfo info(rawPath);
if (!info.exists() || !info.isFile()) {
qWarning() << "Workspace: skipping" << rawPath
<< ": file not found or not a file";
error = QStringLiteral("File not found: %1").arg(info.fileName());
continue;
}
const QString mimeType = mimeDatabase.mimeTypeForFile(info).name();
const bool isImage =
mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive);
const qint64 cap = isImage ? MaxImageBytes : MaxFileBytes;
if (info.size() > cap) {
qWarning() << "Workspace: skipping" << rawPath << ": too large ("
<< info.size() << "bytes, cap" << cap << ")";
error = QStringLiteral("File too large: %1").arg(info.fileName());
continue;
}
QString destination = m_path + QLatin1Char('/') + info.fileName();
int suffix = 1;
while (QFile::exists(destination)) {
const QString extension = info.suffix().isEmpty()
? QString()
: QLatin1Char('.') + info.suffix();
destination = m_path + QLatin1Char('/') + info.completeBaseName() +
QStringLiteral("_%1%2").arg(suffix).arg(extension);
++suffix;
}
if (!QFile::copy(info.absoluteFilePath(), destination)) {
qWarning() << "Workspace: skipping" << rawPath << ": copy to"
<< destination << "failed";
error =
QStringLiteral("Could not copy file: %1").arg(info.fileName());
continue;
}
result.append(new Attachment(
info.fileName(),
destination,
isImage ? Attachment::Kind::Image : Attachment::Kind::File,
mimeType,
info.size(),
m_session));
}
return result;
}
} // namespace ZShell::llm
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "attachment.hpp"
#include <QObject>
#include <QString>
#include <QStringList>
namespace ZShell::llm {
class ChatSession;
class Workspace : public QObject {
Q_OBJECT
public:
explicit Workspace(ChatSession* session, QObject* parent = nullptr);
[[nodiscard]] QString path() const { return m_path; }
[[nodiscard]] static QString pathFor(const QString& sessionId);
[[nodiscard]] bool ensure();
[[nodiscard]] QList<Attachment*> importFiles(
const QStringList& paths, QString& error);
static constexpr qint64 MaxImageBytes = 20 * 1024 * 1024;
static constexpr qint64 MaxFileBytes = 100 * 1024 * 1024;
private:
ChatSession* m_session;
QString m_path;
};
} // namespace ZShell::llm