A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.
deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
the model; cst_attach_import_artifact() stores any bytes up to
CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
skips for_user entries without even a "not sent" note, and a turn that only
delivered a file no longer invents an empty user message. tests/test_artifacts.c
pins that down end to end, including a session-file round trip.
The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
entry is a file card (extension badge, name, type and size, Open, Share);
a picture stays a thumbnail and opens on tap. Both shapes are read, so
sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
the session's own messages carries it, and hand the display name to the
platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
resolveActivity() checked, read permission granted) and shares through the
share sheet; a desktop uses QDesktopServices / the clipboard.
CST_APP_NO_OPEN=1 makes --uitest run without opening anything.
Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
`for_user`: the file survived the file, but came back as an attachment --
no card, and put back in front of the model. It now uses the one writer
(cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
card that is still running -- and the engine reports the result *before*
the files, so the card was already complete and the files were dropped.
A live turn therefore showed no card while a replay of the same session
did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
never drawn. The card now carries the reason ("no app on the phone opens
report.md").
Also
- The saved provider key reaches the engine on every start: AgentCore::open()
was handed an empty key and the engine never resolves credentials itself
(only the CLI did), so every start asked for it again. It resolves the same
order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
and keeps failing on the NX789J -- the build() returns null and Qt clears
the exception before C++ can read it, so the call has to be wrapped in Java
next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
refused), the live order of result-then-files, and the startup row count.
Version 0.9.2.
311 lines
12 KiB
C++
311 lines
12 KiB
C++
/* cst-agent-app: the Qt front end.
|
|
*
|
|
* cst-agent-app --workspace DIR [--provider p] [--model m] [--api-key k]
|
|
* cst-agent-app --size WxH window geometry (480x900, 1180x800, ...)
|
|
* cst-agent-app --selftest embedding checks, no window, no network
|
|
*
|
|
* The engine runs in this process (see app/README.md); nothing listens on a
|
|
* port and no second process is started. */
|
|
|
|
#include <QCommandLineParser>
|
|
#include <QCoreApplication>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QGuiApplication>
|
|
#include <QQmlApplicationEngine>
|
|
#include <QQmlContext>
|
|
#include <QQuickStyle>
|
|
#include <QStandardPaths>
|
|
#include <QTemporaryDir>
|
|
#include <QTextStream>
|
|
#include <QUrl>
|
|
|
|
#if defined(Q_OS_ANDROID)
|
|
#include <QJniObject>
|
|
#include <QtCore/private/qandroidextras_p.h>
|
|
#endif
|
|
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <memory>
|
|
|
|
#include "core/AgentCore.h"
|
|
#include "core/ToolProvider.h"
|
|
#include "ui/UiTest.h"
|
|
#include "ui/SessionImages.h"
|
|
#include "ui/ThemeIcons.h"
|
|
#include "cst_version.h"
|
|
|
|
#if defined(Q_OS_ANDROID)
|
|
/* Where an Android app is allowed to write: its own data directory. There is
|
|
* no usable $HOME, no writable "/" (QDir::currentPath()) and no /tmp, while
|
|
* the engine wants a config dir ($XDG_CONFIG_HOME/cst-agent, os_config_dir())
|
|
* and a scratch dir (the first writable entry of os_temp_dir()'s list). All
|
|
* of them are pointed here -- once, before the engine exists. */
|
|
QString androidRoot() {
|
|
const QString root =
|
|
QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
|
return root.isEmpty() ? QDir::tempPath() + QStringLiteral("/cst-agent")
|
|
: root;
|
|
}
|
|
#endif
|
|
|
|
namespace {
|
|
|
|
int runSelftest() {
|
|
QTextStream out(stdout);
|
|
int bad = 0;
|
|
QTemporaryDir tmp, cfg;
|
|
if (!tmp.isValid() || !cfg.isValid()) {
|
|
out << "selftest: cannot create a scratch dir\n";
|
|
return 1;
|
|
}
|
|
/* The window passes no --api-key, so the key the user saved for the provider
|
|
* has to come back from <config>/cst-agent/credentials -- the file the
|
|
* settings dialog writes. Planted in a scratch config dir, never in the
|
|
* real one. */
|
|
const QString cfgdir = cfg.path() + QStringLiteral("/cst-agent");
|
|
QDir().mkpath(cfgdir);
|
|
QFile creds(cfgdir + QStringLiteral("/credentials"));
|
|
if (!creds.open(QIODevice::WriteOnly | QIODevice::Truncate) ||
|
|
creds.write("deepseek.api_key=selftest-saved\n") < 0) {
|
|
out << "selftest: cannot write a scratch credentials file\n";
|
|
return 1;
|
|
}
|
|
creds.close();
|
|
qputenv("XDG_CONFIG_HOME", cfg.path().toUtf8());
|
|
|
|
AgentCore core;
|
|
QString err;
|
|
if (!core.open(tmp.path(), QStringLiteral("deepseek"), QString(), QString(),
|
|
&err)) {
|
|
out << "selftest: engine did not start: " << err << "\n";
|
|
return 1;
|
|
}
|
|
const cst_agent_config_t *live =
|
|
core.agent() ? cst_agent_config(core.agent()) : nullptr;
|
|
if (!live || strcmp(live->api_key, "selftest-saved") != 0) {
|
|
out << "selftest: the saved key did not reach the engine config\n";
|
|
bad++;
|
|
} else {
|
|
out << "selftest: the saved key is in the engine config\n";
|
|
}
|
|
if (!core.open(tmp.path(), QStringLiteral("deepseek"),
|
|
QStringLiteral("deepseek-flash"), QStringLiteral("test-key"),
|
|
&err)) {
|
|
out << "selftest: engine did not start: " << err << "\n";
|
|
return 1;
|
|
}
|
|
out << "selftest: engine started in-process\n";
|
|
if (core.agent() == nullptr) {
|
|
out << "selftest: no agent handle\n";
|
|
return 1;
|
|
}
|
|
/* the front end's own tools go through the engine's registry */
|
|
const int n = app::registerPlatformTools(&core);
|
|
out << "selftest: platform tools registered: " << n << "\n";
|
|
const cst_tool_def_t *t = cst_tool_find("notification_send");
|
|
if (!t) {
|
|
out << "selftest: notification_send is not in the registry\n";
|
|
bad++;
|
|
} else {
|
|
if (t->owner == nullptr || strncmp(t->owner, "app:", 4) != 0) {
|
|
out << "selftest: notification_send has no app owner\n";
|
|
bad++;
|
|
}
|
|
char why[160] = "";
|
|
const bool ok = cst_tool_available(t, why, sizeof(why)) == 0;
|
|
out << "selftest: notification_send available=" << (ok ? "yes" : "no")
|
|
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
|
|
<< "\n";
|
|
}
|
|
/* The engine's own deliver_file: the file a tool hands to the person. */
|
|
const cst_tool_def_t *dv = cst_tool_find("deliver_file");
|
|
if (!dv) {
|
|
out << "selftest: deliver_file is not in the registry\n";
|
|
bad++;
|
|
} else {
|
|
char why[160] = "";
|
|
const bool ok = cst_tool_available(dv, why, sizeof(why)) == 0;
|
|
out << "selftest: deliver_file available=" << (ok ? "yes" : "no")
|
|
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
|
|
<< "\n";
|
|
}
|
|
/* the mode comes from the engine, not from the UI */
|
|
core.setMode(QStringLiteral("general"));
|
|
if (core.mode() != QLatin1String("general")) {
|
|
out << "selftest: mode did not stick\n";
|
|
bad++;
|
|
}
|
|
app::unregisterPlatformTools(&core);
|
|
if (cst_tool_find("notification_send") != nullptr) {
|
|
out << "selftest: platform tools were not dropped\n";
|
|
bad++;
|
|
}
|
|
core.close();
|
|
out << (bad ? "selftest: FAILED\n" : "selftest: OK\n");
|
|
out.flush();
|
|
return bad ? 1 : 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char **argv) {
|
|
/* --selftest stays headless: no window, no QML, no display needed. */
|
|
bool selftest = false;
|
|
for (int i = 1; i < argc; i++)
|
|
if (strcmp(argv[i], "--selftest") == 0)
|
|
selftest = true;
|
|
if (selftest) {
|
|
QCoreApplication app(argc, argv);
|
|
return runSelftest();
|
|
}
|
|
|
|
QGuiApplication app(argc, argv);
|
|
/* The window styles its own controls; the platform style only adds noise
|
|
* here (the KDE Breeze plugin logs a line per registered type and trips
|
|
* over the composer's TextArea). An explicit choice still wins. */
|
|
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE"))
|
|
QQuickStyle::setStyle(QStringLiteral("Basic"));
|
|
QGuiApplication::setApplicationName(QStringLiteral("cst-agent-app"));
|
|
QGuiApplication::setApplicationVersion(QStringLiteral(CST_VERSION));
|
|
QGuiApplication::setOrganizationName(QStringLiteral("cst-agent"));
|
|
|
|
QCommandLineParser parser;
|
|
parser.setApplicationDescription(
|
|
QStringLiteral("CST-agent desktop front end (engine in-process)"));
|
|
parser.addHelpOption();
|
|
parser.addVersionOption();
|
|
QCommandLineOption wsOpt({QStringLiteral("w"), QStringLiteral("workspace")},
|
|
QStringLiteral("workspace directory"),
|
|
QStringLiteral("dir"));
|
|
QCommandLineOption providerOpt(QStringLiteral("provider"),
|
|
QStringLiteral("provider name"),
|
|
QStringLiteral("name"));
|
|
QCommandLineOption modelOpt(QStringLiteral("model"),
|
|
QStringLiteral("model name"), QStringLiteral("name"));
|
|
QCommandLineOption planOpt(QStringLiteral("plan"),
|
|
QStringLiteral("start in plan mode"));
|
|
QCommandLineOption uitestOpt(
|
|
QStringLiteral("uitest"),
|
|
QStringLiteral("drive the window with synthesized mouse/touch input and exit"));
|
|
QCommandLineOption sizeOpt(QStringLiteral("size"),
|
|
QStringLiteral("initial window size, e.g. 480x900"),
|
|
QStringLiteral("WxH"));
|
|
parser.addOption(wsOpt);
|
|
parser.addOption(providerOpt);
|
|
parser.addOption(modelOpt);
|
|
parser.addOption(planOpt);
|
|
parser.addOption(uitestOpt);
|
|
parser.addOption(sizeOpt);
|
|
parser.process(app);
|
|
|
|
QString ws = parser.value(wsOpt);
|
|
if (ws.isEmpty()) {
|
|
#if defined(Q_OS_ANDROID)
|
|
ws = androidRoot() + QStringLiteral("/workspace");
|
|
#else
|
|
ws = QDir::currentPath();
|
|
#endif
|
|
}
|
|
ws = QFileInfo(ws).absoluteFilePath();
|
|
|
|
/* --uitest drives a scratch workspace when none was given: the session
|
|
* checks create and delete sessions, which is not something to do in
|
|
* whatever directory the app happened to be started from. */
|
|
std::unique_ptr<QTemporaryDir> scratch;
|
|
if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) {
|
|
scratch.reset(new QTemporaryDir);
|
|
if (scratch->isValid()) {
|
|
ws = scratch->path();
|
|
/* settings.json and credentials live in <config>/cst-agent: point them
|
|
* at the scratch dir too, so a test run never rewrites the user's own
|
|
* provider choice or key. */
|
|
qputenv("XDG_CONFIG_HOME", scratch->path().toUtf8());
|
|
}
|
|
}
|
|
|
|
#if defined(Q_OS_ANDROID)
|
|
/* Before the engine is created: its settings, credentials and session store
|
|
* all resolve their paths from the environment. */
|
|
const QString root = androidRoot();
|
|
for (const QString &sub : {QStringLiteral("config"), QStringLiteral("tmp")})
|
|
QDir().mkpath(root + QLatin1Char('/') + sub);
|
|
qputenv("XDG_CONFIG_HOME", (root + QStringLiteral("/config")).toUtf8());
|
|
qputenv("TMPDIR", (root + QStringLiteral("/tmp")).toUtf8());
|
|
qputenv("HOME", root.toUtf8());
|
|
#endif
|
|
|
|
AgentCore core;
|
|
QString err;
|
|
if (!core.open(ws, parser.value(providerOpt), parser.value(modelOpt),
|
|
QString(), &err)) {
|
|
QTextStream(stderr) << "cannot start the engine: " << err << "\n";
|
|
return 1;
|
|
}
|
|
app::registerPlatformTools(&core); /* the app's own tools */
|
|
#if defined(Q_OS_ANDROID)
|
|
/* notification_send sits behind POST_NOTIFICATIONS from Android 13 on, and
|
|
* the tool is hidden while it is not granted (its probe says so): ask once,
|
|
* here, so the tool is there for the first turn rather than a prompt that
|
|
* shows up mid-answer. Denying it only costs the tool. */
|
|
if (QNativeInterface::QAndroidApplication::context().isValid() &&
|
|
QtAndroidPrivate::checkPermission(
|
|
QStringLiteral("android.permission.POST_NOTIFICATIONS"))
|
|
.result() != QtAndroidPrivate::Authorized) {
|
|
QtAndroidPrivate::requestPermission(
|
|
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
|
|
}
|
|
#endif
|
|
if (parser.isSet(planOpt))
|
|
core.setPlanMode(true);
|
|
|
|
/* --uitest: same window, driven by synthesized input (app/src/ui/UiTest.cpp). */
|
|
if (parser.isSet(uitestOpt))
|
|
return app::runUiTest(core, parser.value(sizeOpt));
|
|
|
|
/* The layout is responsive; --size is how it gets exercised without a
|
|
* different screen (see app/README.md). */
|
|
int winW = 0, winH = 0;
|
|
const QString size = parser.value(sizeOpt);
|
|
if (!size.isEmpty()) {
|
|
const int x = size.indexOf(QLatin1Char('x'));
|
|
bool okW = false, okH = false;
|
|
if (x > 0) {
|
|
winW = size.left(x).toInt(&okW);
|
|
winH = size.mid(x + 1).toInt(&okH);
|
|
}
|
|
if (!okW || !okH || winW < 320 || winH < 360) {
|
|
QTextStream(stderr) << "--size wants WxH (at least 320x360)\n";
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
QQmlApplicationEngine engine;
|
|
/* image://csttheme/ plus the desktop's icon theme behind it. */
|
|
app::installThemeIcons(&engine);
|
|
/* image://cstattach/: the session's own store, so the pictures a tool made
|
|
* (view_image, an MCP image) and the ones the user attached are shown, not
|
|
* only named. The directory follows the session. */
|
|
app::SessionImages *images = app::installSessionImages(&engine);
|
|
images->setDir(core.attachmentDir());
|
|
QObject::connect(&core, &AgentCore::sessionChanged, images,
|
|
[images, &core] { images->setDir(core.attachmentDir()); });
|
|
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core);
|
|
engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), winW);
|
|
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), winH);
|
|
const QUrl url(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml"));
|
|
QObject::connect(
|
|
&engine, &QQmlApplicationEngine::objectCreated, &app,
|
|
[url](QObject *obj, const QUrl &objUrl) {
|
|
if (!obj && url == objUrl)
|
|
QCoreApplication::exit(-1);
|
|
},
|
|
Qt::QueuedConnection);
|
|
engine.load(url);
|
|
if (engine.rootObjects().isEmpty())
|
|
return 1;
|
|
return app.exec();
|
|
}
|