v0.8.12: attachments in the composer, picker + drop + paste

A prompt can carry files now, and they come in the three ways a desktop user
expects: the 📎 button in the composer (QtQuick.Dialogs' FileDialog), files
dropped anywhere on the window (a DropArea over it, with an accent border and
"drop to attach" while something is dragged), and an image on the clipboard
(Ctrl+V). Text keeps the editor's own paste: the composer asks
AgentCore::clipboardHasImage() first and only takes the keystroke when there is
an image.

AgentCore (app/src/core/AgentCore.*):
- pending is a property now (QStringList) fed by cst_agent_pending_describe();
  attachFile() and attachClipboard() wrap cst_agent_attach_file() /
  cst_agent_attach_bytes(), clearPending() wraps cst_agent_pending_clear(), and
  seesImages() reports whether the model is shown images at all.
- the engine's description says what the model gets ("png 12.0 KB"), not where
  it came from, so the window remembers the names it attached and puts them in
  front: "note.txt — text 21 B".
- pendingChanged() fires on attach, on clear and when a turn ends (the prompt
  consumed the list), which is what makes the chips move.

Engine (src/attach.c):
- cst_attach_describe() printed every sub-megabyte size as "%.0f KB": a 21-byte
  text file came out as "text 0 KB". It is "B", "KB" with one decimal or "MB"
  now, and test_attach still passes.

QML: the composer shows one chip per pending file with "clear all"; the hint
line mentions 📎/Ctrl+V and says when the model is text-only (images then
travel as notes); Main.qml owns the DropArea and the drag feedback.

Verified: 44/44 UI checks (8 new: attach ok, one pending, the name is shown, a
missing file is refused, one chip in the composer, clear all empties it, the
clipboard image is seen and attached), --selftest, make check rc=0 (test_attach
included), and screenshots of the chip row.

Version 0.8.12.
This commit is contained in:
WHC
2026-10-11 12:35:22 +08:00
parent be4025e276
commit cbd71b83c7
8 changed files with 367 additions and 8 deletions
+88
View File
@@ -1,8 +1,12 @@
#include "AgentCore.h"
#include <QBuffer>
#include <QClipboard>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
@@ -182,6 +186,9 @@ void AgentCore::finishTurn(int rc, const QString &err) {
Turn *t = static_cast<Turn *>(sender());
post([this, rc, err] {
setRunning(false);
/* The prompt carried whatever was pending; the engine dropped it. */
m_attachNames.clear();
emit pendingChanged();
emit turnFinished(rc, err);
});
if (t) {
@@ -664,6 +671,87 @@ QVariantMap AgentCore::usage() const {
return m;
}
/* --- attachments ---------------------------------------------------------- */
QStringList AgentCore::pendingAttachments() const {
QStringList out;
if (!m_agent)
return out;
const int n = cst_agent_pending_count(m_agent);
for (int i = 0; i < n; i++) {
char line[256] = "";
if (cst_agent_pending_describe(m_agent, i, line, sizeof(line)) != 0)
continue;
/* The engine describes what the model gets ("png 12.0 KB"); the file it
* came from is the window's own business, so the name goes in front. */
const QString desc = utf8(line);
out << (i < m_attachNames.size()
? QStringLiteral("%1 — %2").arg(m_attachNames.at(i), desc)
: desc);
}
return out;
}
QString AgentCore::attachFile(const QString &path) {
if (!m_agent)
return QStringLiteral("no engine");
if (path.isEmpty())
return QStringLiteral("no file");
char err[256] = "";
if (cst_agent_attach_file(m_agent, path.toUtf8().constData(), err,
sizeof(err)) != 0)
return utf8(err[0] ? err : "that file cannot be attached");
m_attachNames << QFileInfo(path).fileName();
emit pendingChanged();
return QString();
}
QString AgentCore::attachClipboard() {
if (!m_agent)
return QStringLiteral("no engine");
QGuiApplication *gui =
qobject_cast<QGuiApplication *>(QCoreApplication::instance());
if (!gui || !gui->clipboard())
return QStringLiteral("no clipboard here");
/* An image is what this is for: text belongs to the editor's own paste. */
const QImage img = gui->clipboard()->image();
if (img.isNull())
return QStringLiteral("no image on the clipboard");
QByteArray png;
QBuffer buf(&png);
buf.open(QIODevice::WriteOnly);
if (!img.save(&buf, "PNG"))
return QStringLiteral("cannot encode the clipboard image");
char err[256] = "";
if (cst_agent_attach_bytes(m_agent, "clipboard.png", png.constData(),
static_cast<size_t>(png.size()), err,
sizeof(err)) != 0)
return utf8(err[0] ? err : "the clipboard image was refused");
m_attachNames << QStringLiteral("clipboard.png");
emit pendingChanged();
return QString();
}
void AgentCore::clearPending() {
if (!m_agent)
return;
cst_agent_pending_clear(m_agent);
m_attachNames.clear();
emit pendingChanged();
}
bool AgentCore::clipboardHasImage() const {
QGuiApplication *gui =
qobject_cast<QGuiApplication *>(QCoreApplication::instance());
if (!gui || !gui->clipboard())
return false;
return !gui->clipboard()->image().isNull();
}
int AgentCore::seesImages() const {
return m_agent ? cst_agent_sees_images(m_agent) : 0;
}
/* --- tools the front end provides ---------------------------------------- */
bool AgentCore::registerTool(const cst_tool_def_t *def) {