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
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:
+78
-202
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user