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:
+29
-1
@@ -148,6 +148,33 @@ into the field reaches the engine when "Use this" is clicked, the note says so,
|
|||||||
and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch
|
and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch
|
||||||
directory, so settings and keys of the real user are never rewritten.
|
directory, so settings and keys of the real user are never rewritten.
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
|
||||||
|
The next prompt can carry files, and they arrive three ways:
|
||||||
|
|
||||||
|
- **📎 in the composer** opens a file picker (`QtQuick.Dialogs`).
|
||||||
|
- **Dropping files on the window** — a `DropArea` covers it; while something is
|
||||||
|
dragged over, the border turns accent and says "drop to attach".
|
||||||
|
- **Ctrl+V** with an image on the clipboard. Text is left to the editor's own
|
||||||
|
paste: the composer asks `AgentCore::clipboardHasImage()` first and only
|
||||||
|
intercepts the keystroke when there is an image.
|
||||||
|
|
||||||
|
`AgentCore::attachFile()` / `attachClipboard()` call `cst_agent_attach_file()` /
|
||||||
|
`cst_agent_attach_bytes()`: the engine validates the file, copies it next to
|
||||||
|
the session (`.agent/sessions/`), and the next `run_turn` carries it — the
|
||||||
|
window only shows what the engine's `pending` list says, one chip per file in
|
||||||
|
the engine's own words ("note.txt (text/plain, 21 B)"), with "clear all"
|
||||||
|
(`cst_agent_pending_clear`). A prompt consumes the list, so the chips go away
|
||||||
|
with the turn. When the model cannot be shown images
|
||||||
|
(`cst_agent_sees_images() == 0`), the composer says so and the engine sends
|
||||||
|
them as text notes instead.
|
||||||
|
|
||||||
|
`--uitest` writes a file into its scratch workspace and checks the whole path:
|
||||||
|
`attachFile` succeeds, the engine reports one pending attachment, a missing file
|
||||||
|
is refused with a reason, the composer shows one chip, "clear all" empties the
|
||||||
|
list, and — with an 8×8 image put on the clipboard — `clipboardHasImage()` and
|
||||||
|
`attachClipboard()` behave.
|
||||||
|
|
||||||
## Tools the app provides
|
## Tools the app provides
|
||||||
|
|
||||||
A tool implemented in the front end is registered with `cst_tool_register()`
|
A tool implemented in the front end is registered with `cst_tool_register()`
|
||||||
@@ -205,7 +232,8 @@ The build flag that keeps Unix helpers out of the picture is
|
|||||||
`--uitest` for mouse, touch and small-screen checks
|
`--uitest` for mouse, touch and small-screen checks
|
||||||
- [x] session list in the sidebar: new / switch / delete, transcript replay
|
- [x] session list in the sidebar: new / switch / delete, transcript replay
|
||||||
- [x] settings dialog: provider, model, key (`cst_ps_*`), token counters
|
- [x] settings dialog: provider, model, key (`cst_ps_*`), token counters
|
||||||
|
- [x] attachments: picker, drop on the window, Ctrl+V image paste
|
||||||
- [ ] a real turn driven through the window (needs a provider key)
|
- [ ] a real turn driven through the window (needs a provider key)
|
||||||
- [ ] attachments (drag in, paste)
|
- [ ] tool results that carry images (they are stored, not drawn yet)
|
||||||
- [ ] Kirigami (Breeze look), tray, global shortcuts
|
- [ ] Kirigami (Breeze look), tray, global shortcuts
|
||||||
- [ ] Android APK (engine in-process, JNI platform tools)
|
- [ ] Android APK (engine in-process, JNI platform tools)
|
||||||
|
|||||||
+138
-3
@@ -1,15 +1,22 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
|
||||||
// The input line: multiline, Enter sends, the button changes with the turn
|
// The input line: multiline, Enter sends, the button changes with the turn
|
||||||
// state. Sized for a thumb as much as for a mouse, and it asks the platform
|
// state. Sized for a thumb as much as for a mouse, and it asks the platform
|
||||||
// keyboard not to autocorrect what is usually code or a path.
|
// keyboard not to autocorrect what is usually code or a path.
|
||||||
|
//
|
||||||
|
// Files arrive three ways -- the 📎 picker, a drop on the window (Main.qml)
|
||||||
|
// and an image on the clipboard (Ctrl+V) -- and all of them end up in the
|
||||||
|
// engine's pending list, which the next prompt carries.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
objectName: "composer"
|
objectName: "composer"
|
||||||
property bool running: false
|
property bool running: false
|
||||||
property bool compact: false
|
property bool compact: false
|
||||||
|
property string note: "" // a failure, or what was just attached
|
||||||
|
property var attachments: [] // AgentCore::pending, "name (mime, 12 KB)"
|
||||||
property alias text: input.text
|
property alias text: input.text
|
||||||
signal submitted(string text)
|
signal submitted(string text)
|
||||||
|
|
||||||
@@ -21,6 +28,22 @@ Rectangle {
|
|||||||
submitted(t)
|
submitted(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A file: from the picker, or a URL dropped on the window. */
|
||||||
|
function localPath(url) {
|
||||||
|
let s = String(url)
|
||||||
|
if (s.startsWith("file://"))
|
||||||
|
s = s.substring(7)
|
||||||
|
/* Windows keeps its drive letter after the slash. */
|
||||||
|
if (/^\/[A-Za-z]:/.test(s))
|
||||||
|
s = s.substring(1)
|
||||||
|
return decodeURIComponent(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachPath(path) {
|
||||||
|
const r = agent.attachFile(path)
|
||||||
|
note = r.length ? r : ""
|
||||||
|
}
|
||||||
|
|
||||||
radius: Theme.radius
|
radius: Theme.radius
|
||||||
color: Theme.surface
|
color: Theme.surface
|
||||||
border.width: 1
|
border.width: 1
|
||||||
@@ -37,6 +60,70 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
|
// What the next prompt will carry, in the engine's own words.
|
||||||
|
Flow {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: root.attachments.length > 0
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.attachments
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: chip
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
objectName: "attachChip" + chip.index
|
||||||
|
implicitWidth: chipText.implicitWidth + 14
|
||||||
|
implicitHeight: 24
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.surfaceAlt
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.border
|
||||||
|
Text {
|
||||||
|
id: chipText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: String(chip.modelData)
|
||||||
|
color: Theme.textDim
|
||||||
|
font.pixelSize: Theme.smallSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: clearAtt
|
||||||
|
objectName: "clearAttachments"
|
||||||
|
text: "clear all"
|
||||||
|
implicitHeight: 24
|
||||||
|
onClicked: {
|
||||||
|
agent.clearPending()
|
||||||
|
root.note = ""
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: clearAtt.hovered ? Theme.surfaceHi : "transparent"
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.border
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: clearAtt.text
|
||||||
|
color: clearAtt.hovered ? Theme.text : Theme.textFaint
|
||||||
|
font.pixelSize: Theme.smallSize
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: root.note.length > 0
|
||||||
|
text: root.note
|
||||||
|
color: Theme.warn
|
||||||
|
font.pixelSize: Theme.smallSize
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: Theme.gap
|
spacing: Theme.gap
|
||||||
@@ -69,10 +156,46 @@ Rectangle {
|
|||||||
root.submit()
|
root.submit()
|
||||||
ev.accepted = true
|
ev.accepted = true
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* An image has nothing the editor could paste, so Ctrl+V
|
||||||
|
* becomes an attachment there; text stays with the
|
||||||
|
* editor's own paste. */
|
||||||
|
if ((ev.modifiers & Qt.ControlModifier) && ev.key === Qt.Key_V &&
|
||||||
|
agent.clipboardHasImage()) {
|
||||||
|
root.note = agent.attachClipboard()
|
||||||
|
ev.accepted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToolButton {
|
||||||
|
id: attach
|
||||||
|
objectName: "attachButton"
|
||||||
|
Layout.alignment: Qt.AlignBottom
|
||||||
|
implicitWidth: root.compact ? Theme.touchTarget : 40
|
||||||
|
implicitHeight: Theme.touchTarget + 4
|
||||||
|
enabled: !root.running
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: "Attach a file — or drop one on the window, or paste an image"
|
||||||
|
contentItem: Text {
|
||||||
|
text: "📎"
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim)
|
||||||
|
: Theme.textFaint
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: attach.hovered && attach.enabled ? Theme.surfaceHi
|
||||||
|
: "transparent"
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.border
|
||||||
|
}
|
||||||
|
onClicked: picker.open()
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
id: action
|
id: action
|
||||||
objectName: "sendButton"
|
objectName: "sendButton"
|
||||||
@@ -103,11 +226,23 @@ Rectangle {
|
|||||||
Text {
|
Text {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
visible: !root.compact
|
visible: !root.compact
|
||||||
text: root.running
|
text: {
|
||||||
? "working — Esc cancels the turn"
|
if (root.running)
|
||||||
: "Enter sends · Shift+Enter newline · Esc stops · every change asks first"
|
return "working — Esc cancels the turn"
|
||||||
|
let s = "Enter sends · Shift+Enter newline · Esc stops · 📎 or Ctrl+V attaches"
|
||||||
|
if (root.attachments.length > 0 && agent.seesImages() === 0)
|
||||||
|
s += " · this model is text-only, images arrive as notes"
|
||||||
|
return s
|
||||||
|
}
|
||||||
color: Theme.textFaint
|
color: Theme.textFaint
|
||||||
font.pixelSize: Theme.smallSize
|
font.pixelSize: Theme.smallSize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: picker
|
||||||
|
title: "Attach a file"
|
||||||
|
fileMode: FileDialog.OpenFile
|
||||||
|
onAccepted: root.attachPath(root.localPath(selectedFile))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,18 +326,61 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Composer {
|
Composer {
|
||||||
|
id: composer
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL
|
Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL
|
||||||
Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL
|
Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL
|
||||||
Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL
|
Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL
|
||||||
running: agent.running
|
running: agent.running
|
||||||
compact: win.compact
|
compact: win.compact
|
||||||
|
attachments: agent.pending
|
||||||
onSubmitted: function (text) { win.submit(text) }
|
onSubmitted: function (text) { win.submit(text) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- compact: the sidebar slides in over the transcript --------------
|
// ---- compact: the sidebar slides in over the transcript --------------
|
||||||
|
/* Files dropped anywhere on the window: the engine copies them next to
|
||||||
|
* the session and the next prompt carries them. The picker and Ctrl+V in
|
||||||
|
* the composer land in the same list. */
|
||||||
|
DropArea {
|
||||||
|
id: dropZone
|
||||||
|
objectName: "dropZone"
|
||||||
|
anchors.fill: parent
|
||||||
|
onDropped: function (drop) {
|
||||||
|
if (!drop.hasUrls)
|
||||||
|
return
|
||||||
|
let note = ""
|
||||||
|
let n = 0
|
||||||
|
for (let i = 0; i < drop.urls.length; i++) {
|
||||||
|
const r = agent.attachFile(composer.localPath(drop.urls[i]))
|
||||||
|
if (r.length)
|
||||||
|
note = r
|
||||||
|
else
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
composer.note = note.length ? note
|
||||||
|
: n + (n === 1 ? " file attached" : " files attached")
|
||||||
|
drop.accept()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// While something is dragged over the window, say what will happen.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: dropZone.containsDrag
|
||||||
|
color: "transparent"
|
||||||
|
border.width: 2
|
||||||
|
border.color: Theme.accent
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "drop to attach"
|
||||||
|
color: Theme.accent
|
||||||
|
font.pixelSize: Theme.bodySize
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: scrim
|
id: scrim
|
||||||
objectName: "navScrim"
|
objectName: "navScrim"
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
#include "AgentCore.h"
|
#include "AgentCore.h"
|
||||||
|
|
||||||
|
#include <QBuffer>
|
||||||
|
#include <QClipboard>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QImage>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
@@ -182,6 +186,9 @@ void AgentCore::finishTurn(int rc, const QString &err) {
|
|||||||
Turn *t = static_cast<Turn *>(sender());
|
Turn *t = static_cast<Turn *>(sender());
|
||||||
post([this, rc, err] {
|
post([this, rc, err] {
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
|
/* The prompt carried whatever was pending; the engine dropped it. */
|
||||||
|
m_attachNames.clear();
|
||||||
|
emit pendingChanged();
|
||||||
emit turnFinished(rc, err);
|
emit turnFinished(rc, err);
|
||||||
});
|
});
|
||||||
if (t) {
|
if (t) {
|
||||||
@@ -664,6 +671,87 @@ QVariantMap AgentCore::usage() const {
|
|||||||
return m;
|
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 ---------------------------------------- */
|
/* --- tools the front end provides ---------------------------------------- */
|
||||||
|
|
||||||
bool AgentCore::registerTool(const cst_tool_def_t *def) {
|
bool AgentCore::registerTool(const cst_tool_def_t *def) {
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ class AgentCore : public QObject {
|
|||||||
bool planMode() const { return m_cfg.plan_mode != 0; }
|
bool planMode() const { return m_cfg.plan_mode != 0; }
|
||||||
QString todo() const { return m_todo; }
|
QString todo() const { return m_todo; }
|
||||||
QString sessionId() const;
|
QString sessionId() const;
|
||||||
|
|
||||||
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
|
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
|
||||||
* message looks the same here as it does in the WebUI. */
|
* message looks the same here as it does in the WebUI. */
|
||||||
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
|
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
|
||||||
@@ -92,6 +91,25 @@ class AgentCore : public QObject {
|
|||||||
/* Tokens of this session and of the last turn, plus the cost when priced. */
|
/* Tokens of this session and of the last turn, plus the cost when priced. */
|
||||||
Q_INVOKABLE QVariantMap usage() const;
|
Q_INVOKABLE QVariantMap usage() const;
|
||||||
|
|
||||||
|
/* --- attachments (see cst_attach.h) ------------------------------------- */
|
||||||
|
|
||||||
|
Q_PROPERTY(QStringList pending READ pendingAttachments NOTIFY pendingChanged)
|
||||||
|
/* What the next prompt will carry: "<name> (<mime>, 12 KB)" per line. */
|
||||||
|
QStringList pendingAttachments() const;
|
||||||
|
/* Copy a file next to the session and give it to the next prompt. "" when
|
||||||
|
* it worked, else why not. */
|
||||||
|
Q_INVOKABLE QString attachFile(const QString &path);
|
||||||
|
/* The same for an image on the clipboard (Ctrl+V). "" when it worked; a
|
||||||
|
* reason -- "no image on the clipboard" -- when the editor should have the
|
||||||
|
* keystroke instead. */
|
||||||
|
Q_INVOKABLE QString attachClipboard();
|
||||||
|
/* Whether Ctrl+V should become an attachment at all (an image is on the
|
||||||
|
* clipboard) or stay with the editor (text). */
|
||||||
|
Q_INVOKABLE bool clipboardHasImage() const;
|
||||||
|
Q_INVOKABLE void clearPending();
|
||||||
|
/* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */
|
||||||
|
Q_INVOKABLE int seesImages() const;
|
||||||
|
|
||||||
/* The engine, for the parts that need it (tool registration). */
|
/* The engine, for the parts that need it (tool registration). */
|
||||||
cst_agent_t *agent() const { return m_agent; }
|
cst_agent_t *agent() const { return m_agent; }
|
||||||
const cst_agent_config_t *config() const { return &m_cfg; }
|
const cst_agent_config_t *config() const { return &m_cfg; }
|
||||||
@@ -126,6 +144,7 @@ class AgentCore : public QObject {
|
|||||||
void planModeChanged();
|
void planModeChanged();
|
||||||
void sessionChanged(); /* another session is open now */
|
void sessionChanged(); /* another session is open now */
|
||||||
void sessionListChanged(); /* the store gained, lost or renamed one */
|
void sessionListChanged(); /* the store gained, lost or renamed one */
|
||||||
|
void pendingChanged(); /* attachments for the next prompt changed */
|
||||||
void statusChanged(const QString &text);
|
void statusChanged(const QString &text);
|
||||||
void assistantDelta(const QString &text);
|
void assistantDelta(const QString &text);
|
||||||
void thinkDelta(const QString &text);
|
void thinkDelta(const QString &text);
|
||||||
@@ -185,6 +204,9 @@ class AgentCore : public QObject {
|
|||||||
bool m_permPending = false;
|
bool m_permPending = false;
|
||||||
QString m_question;
|
QString m_question;
|
||||||
QString m_todo;
|
QString m_todo;
|
||||||
|
/* Names of the files this window attached, in the engine's pending order:
|
||||||
|
* the engine's own description says what it is, not where it came from. */
|
||||||
|
QStringList m_attachNames;
|
||||||
QByteArray m_answer;
|
QByteArray m_answer;
|
||||||
bool m_questionPending = false;
|
bool m_questionPending = false;
|
||||||
bool m_autoApprove = false;
|
bool m_autoApprove = false;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
#include "UiTest.h"
|
#include "UiTest.h"
|
||||||
|
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QImage>
|
||||||
#include <QPointingDevice>
|
#include <QPointingDevice>
|
||||||
#include <QQuickItem>
|
#include <QQuickItem>
|
||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
@@ -330,6 +334,44 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
check(false, QStringLiteral("the dialog has a model field"));
|
check(false, QStringLiteral("the dialog has a model field"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- attachments: a file goes next to the session -------------------- */
|
||||||
|
const QString att = core.workspace() + QStringLiteral("/note.txt");
|
||||||
|
{
|
||||||
|
QFile f(att);
|
||||||
|
f.open(QIODevice::WriteOnly);
|
||||||
|
f.write("hello from a fixture\n");
|
||||||
|
}
|
||||||
|
check(core.attachFile(att).isEmpty(),
|
||||||
|
QStringLiteral("a file on disk can be attached"));
|
||||||
|
check(core.pendingAttachments().size() == 1,
|
||||||
|
QStringLiteral("the engine reports one attachment for the next prompt"));
|
||||||
|
check(core.pendingAttachments().first().contains(QLatin1String("note.txt")),
|
||||||
|
QStringLiteral("the attachment names the file it came from"));
|
||||||
|
check(!core.attachFile(QStringLiteral("/no/such/file-here")).isEmpty(),
|
||||||
|
QStringLiteral("a missing file is refused with a reason"));
|
||||||
|
QTest::qWait(120);
|
||||||
|
check(countNamed(win->contentItem(), QStringLiteral("attachChip")) == 1,
|
||||||
|
QStringLiteral("the composer lists it"));
|
||||||
|
maybeShot(win, QStringLiteral("-attach"));
|
||||||
|
clickMouse(win, QStringLiteral("clearAttachments"));
|
||||||
|
check(core.pendingAttachments().isEmpty(),
|
||||||
|
QStringLiteral("clear all drops them"));
|
||||||
|
|
||||||
|
/* the paste path: an image on the clipboard becomes an attachment */
|
||||||
|
QGuiApplication *gui = qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
||||||
|
if (gui && gui->clipboard()) {
|
||||||
|
QImage img(8, 8, QImage::Format_RGB32);
|
||||||
|
img.fill(Qt::red);
|
||||||
|
gui->clipboard()->setImage(img);
|
||||||
|
check(core.clipboardHasImage(), QStringLiteral("the clipboard image is seen"));
|
||||||
|
check(core.attachClipboard().isEmpty() &&
|
||||||
|
core.pendingAttachments().size() == 1,
|
||||||
|
QStringLiteral("an image on the clipboard is attached"));
|
||||||
|
core.clearPending();
|
||||||
|
} else {
|
||||||
|
check(false, QStringLiteral("there is a clipboard to paste from"));
|
||||||
|
}
|
||||||
|
|
||||||
/* --- a narrow window: dock -> drawer, and the button opens it --- */
|
/* --- a narrow window: dock -> drawer, and the button opens it --- */
|
||||||
resizeTo(win, 460, 900);
|
resizeTo(win, 460, 900);
|
||||||
check(docked && !docked->isVisible(),
|
check(docked && !docked->isVisible(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
#define CST_VERSION_MAJOR 0
|
#define CST_VERSION_MAJOR 0
|
||||||
#define CST_VERSION_MINOR 8
|
#define CST_VERSION_MINOR 8
|
||||||
#define CST_VERSION_PATCH 11
|
#define CST_VERSION_PATCH 12
|
||||||
|
|
||||||
#define CST_VERSION_STR_(x) #x
|
#define CST_VERSION_STR_(x) #x
|
||||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||||
|
|||||||
+3
-2
@@ -618,9 +618,10 @@ void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
|
|||||||
if (a->bytes >= 1024 * 1024)
|
if (a->bytes >= 1024 * 1024)
|
||||||
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
|
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
|
||||||
(double)a->bytes / (1024 * 1024));
|
(double)a->bytes / (1024 * 1024));
|
||||||
|
else if (a->bytes >= 1024)
|
||||||
|
snprintf(out, out_sz, "%s%s %.1f KB", dim, kind, (double)a->bytes / 1024);
|
||||||
else
|
else
|
||||||
snprintf(out, out_sz, "%s%s %.0f KB", dim, kind,
|
snprintf(out, out_sz, "%s%s %lld B", dim, kind, a->bytes);
|
||||||
(double)a->bytes / 1024);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- lifetime ------------------------------------------------------------ */
|
/* --- lifetime ------------------------------------------------------------ */
|
||||||
|
|||||||
Reference in New Issue
Block a user