new chatlistview component, file attachments, performance & primitive test

This commit is contained in:
2026-09-30 15:01:28 +02:00
parent 51ed270774
commit d8a3d5732e
36 changed files with 4368 additions and 310 deletions
+174 -6
View File
@@ -1,16 +1,31 @@
#include "session.hpp"
#include "attachment.hpp"
#include "chatstore.hpp"
#include "llmclient.hpp"
#include <QClipboard>
#include <QDebug>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QMimeDatabase>
#include <QMimeData>
#include <QMimeType>
#include <QStandardPaths>
#include <QUrl>
#include <QtGlobal>
namespace ZShell::llm {
namespace {
constexpr qint64 kMaxImageBytes = 20 * 1024 * 1024;
constexpr qint64 kMaxFileBytes = 100 * 1024 * 1024;
QString titleFrom(const QString& content) {
const QString flat = content.simplified();
if (flat.isEmpty()) return QString();
@@ -21,7 +36,7 @@ QString titleFrom(const QString& content) {
} // namespace
ChatSession::ChatSession(const QString& id, QObject* parent)
: QObject(parent), m_id(id) {
: QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) {
m_model = new ChatMessageModel(this, this);
connect(
m_model,
@@ -157,26 +172,179 @@ void ChatSession::startGeneration(ChatMessage* target) {
persist();
}
QString ChatSession::sandboxDirFor(const QString& sessionId) {
const QString state =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation);
return state + QStringLiteral("/zshell/sandboxes/") + sessionId;
}
void ChatSession::ensureWorkspace() {
QDir().mkpath(m_workspace);
}
void ChatSession::setAttachmentError(const QString& value) {
if (m_attachmentError == value) return;
m_attachmentError = value;
Q_EMIT attachmentErrorChanged();
}
QVariantList ChatSession::addAttachments(const QStringList& paths) {
ensureWorkspace();
setAttachmentError(QString());
QVariantList result;
for (const QString& rawPath : paths) {
const QFileInfo info(rawPath);
if (!info.exists() || !info.isFile()) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": file not found or not a file";
setAttachmentError(
QStringLiteral("File not found: %1").arg(info.fileName()));
continue;
}
const QString mimeType = QMimeDatabase().mimeTypeForFile(info).name();
const bool isImage =
mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive);
const qint64 cap = isImage ? kMaxImageBytes : kMaxFileBytes;
if (info.size() > cap) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": too large (" << info.size() << "bytes, cap" << cap
<< ")";
setAttachmentError(
QStringLiteral("File too large: %1").arg(info.fileName()));
continue;
}
QString destination = m_workspace + QLatin1Char('/') + info.fileName();
int suffix = 1;
while (QFile::exists(destination)) {
const QString extension = info.suffix().isEmpty()
? QString()
: QLatin1Char('.') + info.suffix();
destination = m_workspace + QLatin1Char('/') +
info.completeBaseName() +
QStringLiteral("_%1%2").arg(suffix).arg(extension);
++suffix;
}
if (!QFile::copy(info.absoluteFilePath(), destination)) {
qWarning() << "ChatSession: addAttachments skipping" << rawPath
<< ": copy to" << destination << "failed";
setAttachmentError(
QStringLiteral("Could not copy file: %1").arg(info.fileName()));
continue;
}
auto* attachment = new Attachment(
info.fileName(),
destination,
isImage ? Attachment::Kind::Image : Attachment::Kind::File,
mimeType,
info.size(),
this);
result.append(QVariant::fromValue(attachment));
}
return result;
}
bool ChatSession::clipboardHasAttachment() {
QClipboard* clipboard = QGuiApplication::clipboard();
if (!clipboard) return false;
const QMimeData* data = clipboard->mimeData();
if (!data) return false;
if (data->hasImage()) return true;
for (const QUrl& url : data->urls())
if (url.isLocalFile()) return true;
return false;
}
QVariantList ChatSession::pasteAttachments() {
QClipboard* clipboard = QGuiApplication::clipboard();
const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr;
if (!data) {
qWarning() << "ChatSession: pasteAttachments: no clipboard data";
return {};
}
qInfo() << "ChatSession: pasteAttachments: formats =" << data->formats()
<< "hasImage =" << data->hasImage()
<< "hasUrls =" << data->hasUrls() << "hasText =" << data->hasText();
QStringList paths;
QString tempImagePath;
if (data->hasImage()) {
const QImage image = clipboard->image();
qInfo() << "ChatSession: pasteAttachments: image"
<< (image.isNull() ? "null"
: QString::number(image.width()) + "x" +
QString::number(image.height()));
if (image.isNull()) {
setAttachmentError(
QStringLiteral("Clipboard image could not be decoded"));
return {};
}
tempImagePath =
QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png")
.arg(QDateTime::currentMSecsSinceEpoch());
if (!image.save(tempImagePath)) {
qWarning() << "ChatSession: pasteAttachments: saving image to"
<< tempImagePath << "failed";
setAttachmentError(
QStringLiteral("Could not save the pasted image"));
return {};
}
paths.append(tempImagePath);
} else if (data->hasUrls()) {
for (const QUrl& url : data->urls()) {
qInfo() << "ChatSession: pasteAttachments: url =" << url.toString()
<< "local =" << url.isLocalFile();
if (url.isLocalFile()) paths.append(url.toLocalFile());
}
}
if (paths.isEmpty()) {
if (data->hasImage() || data->hasUrls())
setAttachmentError(QStringLiteral(
"Nothing usable on the clipboard (expected an image "
"or copied files)"));
return {};
}
const QVariantList attachments = addAttachments(paths);
if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath);
qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size()
<< "attachment(s)";
return attachments;
}
void ChatSession::sendMessage(const QString& text) {
sendMessage(text, QVariantList());
}
void ChatSession::sendMessage(
const QString& text, const QVariantList& attachments) {
QList<Attachment*> attachmentList;
for (const QVariant& variant : attachments)
if (auto* attachment =
qobject_cast<Attachment*>(variant.value<QObject*>()))
attachmentList.append(attachment);
const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) return;
if (trimmed.isEmpty() && attachmentList.isEmpty()) return;
if (auto* clientObject = client()) {
if (clientObject->busy()) return;
}
const qint64 now = QDateTime::currentMSecsSinceEpoch();
appendNewest(ChatMessage::Role::User, trimmed, now);
auto* userMessage = appendNewest(ChatMessage::Role::User, trimmed, now);
if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList);
while (m_model->rowCount() > 200)
m_model->removeMessage(m_model->at(m_model->rowCount() - 1));
if (m_title.isEmpty()) {
m_title = titleFrom(trimmed);
QString titleSource = trimmed;
if (titleSource.isEmpty() && !attachmentList.isEmpty())
titleSource = attachmentList.first()->name();
m_title = titleFrom(titleSource);
Q_EMIT titleChanged();
qInfo() << "ChatSession:" << m_id << "new conversation,"
<< "fallback title" << m_title
<< "- requesting generated title and icon";
if (auto* clientObject = client()) {
clientObject->requestTitle(this, trimmed);
clientObject->requestIcon(this, trimmed);
clientObject->requestTitle(this, titleSource);
clientObject->requestIcon(this, titleSource);
}
}
auto* assistant =