v0.9.0: the Qt front end as an Android APK, images in the transcript
Two strands land together: the window draws pictures now, and the whole front end builds, installs and runs on the phone with the engine linked in-process. Images (the "make a picture actually show up" part) - Engine, additively: cst_agent_hooks_t.on_tool_images(call, list, n, ud) fires when a call left attachments behind, and cst_agent_pending_rel() / cst_agent_pending_is_image() read that list without the app touching the store. tests/test_attach.c::test_agent_pending pins the three cases (an image, a stored name that passes cst_attach_rel_ok, out-of-range NULL). - App: image://cstattach/<stored name> (app/src/ui/SessionImages.cpp) serves only the current session's attachment directory, checked with cst_attach_rel_ok, decodes down to the requested sourceSize and never upscales, and holds a mutex because requestImage() runs on the render thread while the directory can change underneath it. AgentCore gained attachmentDir(), pendingImages(), the toolImages()/promptImages() signals and hookToolImages(); history() gives replayed rows their images too. - QML: ImageStrip.qml (a thumb strip, <=150 px tall, shaped by the picture, tooltip with name/size/origin, the file's name when it went missing), wired into MessageCard and ToolCard. Qt 6's ListModel cannot hold a list-valued role (append() reads back undefined, setProperty drops it), so a row carries one image per line as <stored>|<name>|<origin>|<w>x<h> and ImageStrip decodes that. Android (the engine is already in the APK; this is the front end) - app/src/main.cpp: on Android the workspace, config, tmp and HOME go under the app's own data directory before the engine opens anything, because QDir::currentPath() is "/" there and /tmp is not writable. - app/android/: the manifest (QtApplication/QtActivity, INTERNET and POST_NOTIFICATIONS, adjustResize, no storage permission -- the picker will be the system one), strings, a vector icon; minSdk 24 and targetSdk 35 come from app/CMakeLists.txt through QT_ANDROID_MIN/TARGET_SDK_VERSION. - app/src/platform/android/AndroidTools.cpp: the Android file behind PlatformInit(), owner "app:android" (notification, clipboard, share sheet, file picker are stubs for now, so the tool list does not lie about them). - app/tools/selftest.c decides its scratch workspace with os_temp_dir(), so the C smoke test runs on the device too. - scripts/build_apk_android.sh: sources to installed APK in one command (environment checks, qt-cmake configure, --target apk, adb install), and the host-side plumbing the NixOS + qt.io combination needs -- the NDK sysroot include dirs for Qt's FindGLESv2/FindEGL (they only look under <sysroot>/include), -L$PREFIX/lib on the shared/module link lines as well as the executables', the Android debug keystore created on first use with QT_ANDROID_SIGN_APK + QT_ANDROID_KEYSTORE_* (androiddeployqt copies the unsigned gradle output and signs it itself), and the #!/bin/bash shebang of the build-tools wrappers, which NixOS does not have. - scripts/gradle-android-init.gradle (installed into ~/.gradle/init.d/): the release lint task is switched off -- lintVitalAnalyzeRelease resolves its own tool jars straight from dl.google.com, bypassing every repository the build configures, and fails the build after all the real work is done. - Icons: app/icons/*.svg (Breeze's 24@3x files, the 19 names the window asks for) are compiled in as :/cst-icons and ThemeIcons.cpp falls back to them when the platform has no icon theme. On the phone the Qt theme lookup found nothing and the QML glyph fallback was an empty box in the phone's fonts. - docs/android.md: "The APK (Qt front end)" -- the SDK assembled without sdkmanager, the host Qt's missing libraries on NixOS, the Gradle mirrors, lint, signing -- and a Status that says what runs where. app/README.md gains the images and APK lines, and stops claiming Android platform tools that are still stubs. Verified: engine `make check` green; desktop `cst-app-selftest: OK` and --uitest all checks pass; phone (NX789J, Android 16, arm64-v8a) -- the APK installs, starts on the workspace under /data/user/0/xyz.whcself.cstagent/ files/, a turn reaches a configured provider over HTTPS from inside the ART process and renders the reply, rotation keeps the session and docks the sidebar, and the toolbar/card icons draw. Version 0.9.0. mode = coding
This commit is contained in:
@@ -10,10 +10,12 @@
|
||||
|
||||
#include <QGuiApplication>
|
||||
#include <QClipboard>
|
||||
#include <QBuffer>
|
||||
#include <QFile>
|
||||
#include <QImage>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QPainter>
|
||||
#include <QPointingDevice>
|
||||
#include <QQuickItem>
|
||||
#include <QQmlApplicationEngine>
|
||||
@@ -27,6 +29,8 @@
|
||||
#include <cstring>
|
||||
|
||||
#include "cst_agent.h"
|
||||
#include "cst_attach.h"
|
||||
#include "ui/SessionImages.h"
|
||||
#include "ui/ThemeIcons.h"
|
||||
|
||||
namespace app {
|
||||
@@ -118,7 +122,26 @@ QString writeFixtureSession(const QString &ws, const QString &id) {
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.role = CST_ROLE_USER;
|
||||
m.content = strdup("what is here?");
|
||||
/* The prompt carries a picture, stored exactly the way the engine stores
|
||||
* one (the file next to the session, the message naming it). */
|
||||
{
|
||||
QImage pic(320, 200, QImage::Format_RGB32);
|
||||
pic.fill(Qt::darkCyan);
|
||||
QPainter painter(&pic);
|
||||
painter.fillRect(QRect(0, 0, 160, 100), Qt::darkYellow);
|
||||
painter.end();
|
||||
QByteArray png;
|
||||
QBuffer buf(&png);
|
||||
buf.open(QIODevice::WriteOnly);
|
||||
pic.save(&buf, "PNG");
|
||||
char aerr[256] = "";
|
||||
m.attach = cst_attach_import_bytes(path, "shot.png", png.constData(),
|
||||
static_cast<size_t>(png.size()), aerr,
|
||||
sizeof(aerr));
|
||||
m.nattach = m.attach ? 1 : 0;
|
||||
}
|
||||
cst_session_append(s, &m);
|
||||
cst_attachments_free(m.attach, m.nattach);
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.role = CST_ROLE_ASSISTANT;
|
||||
m.content = strdup("let me look. ");
|
||||
@@ -175,6 +198,12 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
installThemeIcons(&engine); /* the window's icons, same as main() */
|
||||
/* image://cstattach/: the session's stored images, pointed at the session
|
||||
* the engine has open (and re-pointed on every switch). */
|
||||
SessionImages *images = 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"), width);
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), height);
|
||||
@@ -303,6 +332,36 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
||||
check(core.sessionId() == fixture && list && list->property("count").toInt() == 5,
|
||||
QStringLiteral("switching back replays it again"));
|
||||
{
|
||||
bool promptImage = false;
|
||||
const QVariantList rows = core.history();
|
||||
for (const QVariant &v : rows) {
|
||||
const QVariantMap r = v.toMap();
|
||||
if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") &&
|
||||
r.value(QStringLiteral("images")).toString().contains(
|
||||
QLatin1String("|")))
|
||||
promptImage = true;
|
||||
}
|
||||
check(promptImage,
|
||||
QStringLiteral("a stored prompt image comes back with its row"));
|
||||
/* The rows are in the model by now; the cards -- and the thumbnails in
|
||||
* them -- are built on the view's next layout pass. A frame forces it,
|
||||
* and that frame is also the picture a person looks at. */
|
||||
maybeShot(win, QStringLiteral("-images"));
|
||||
QTest::qWait(150);
|
||||
if (qEnvironmentVariableIsSet("CST_APP_DUMP")) {
|
||||
QVariant v;
|
||||
QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
|
||||
Q_ARG(QVariant, 0));
|
||||
std::printf("note: replayed row 0: %s\n", qPrintable(v.toString()));
|
||||
std::printf("note: %d strips and %d thumbs in the tree\n",
|
||||
countNamed(win->contentItem(), QStringLiteral("imageStrip")),
|
||||
countNamed(win->contentItem(), QStringLiteral("imageThumb")));
|
||||
}
|
||||
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >= 1,
|
||||
QStringLiteral("the replayed transcript shows it as a thumbnail (%1)")
|
||||
.arg(countNamed(win->contentItem(), QStringLiteral("imageThumb"))));
|
||||
}
|
||||
|
||||
/* --- provider, model, key: the settings dialog ------------------------ */
|
||||
check(core.providers().size() > 0 &&
|
||||
@@ -558,6 +617,72 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
check(false, QStringLiteral("assistant deltas are reachable"));
|
||||
}
|
||||
|
||||
/* --- the image provider: only the session's own files ----------------- */
|
||||
{
|
||||
const QString dir = core.attachmentDir();
|
||||
check(!dir.isEmpty() && dir.contains(QLatin1String("/attachments/")),
|
||||
QStringLiteral("the agent names its image directory (%1)").arg(dir));
|
||||
QImage pic(32, 16, QImage::Format_RGB32);
|
||||
pic.fill(Qt::darkGreen);
|
||||
QByteArray png;
|
||||
QBuffer buf(&png);
|
||||
buf.open(QIODevice::WriteOnly);
|
||||
pic.save(&buf, "PNG");
|
||||
char aerr[256] = "";
|
||||
cst_attachment_t *stored = cst_attach_import_bytes(
|
||||
cst_agent_session_path(core.agent()), "probe.png", png.constData(),
|
||||
static_cast<size_t>(png.size()), aerr, sizeof(aerr));
|
||||
check(stored != nullptr,
|
||||
QStringLiteral("a picture can be stored next to the session"));
|
||||
if (stored) {
|
||||
QSize got;
|
||||
const QImage shrunk =
|
||||
images->requestImage(QString::fromUtf8(stored->rel),
|
||||
&got, QSize(8, 8));
|
||||
check(!shrunk.isNull() && shrunk.width() == 8 && shrunk.height() == 4,
|
||||
QStringLiteral("the provider serves it, shrunk to what was asked "
|
||||
"(%1x%2)")
|
||||
.arg(shrunk.width())
|
||||
.arg(shrunk.height()));
|
||||
check(images->requestImage(QStringLiteral("../escape.png"), &got,
|
||||
QSize(64, 64))
|
||||
.isNull(),
|
||||
QStringLiteral("a name outside the attachment directory is refused"));
|
||||
check(images->requestImage(QStringLiteral("no-such-file.png"), &got,
|
||||
QSize(64, 64))
|
||||
.isNull(),
|
||||
QStringLiteral("a file that is not there is refused"));
|
||||
cst_attach_free(stored);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- a tool that made a picture: its card shows it -------------------- */
|
||||
{
|
||||
const int before = list->property("count").toInt();
|
||||
const int thumbsBefore =
|
||||
countNamed(win->contentItem(), QStringLiteral("imageThumb"));
|
||||
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"),
|
||||
QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}"));
|
||||
const QString imgs =
|
||||
QStringLiteral("img-fixture.png|shot.png|view_image|24x8");
|
||||
check(QMetaObject::invokeMethod(win, "engineToolImages",
|
||||
Q_ARG(QVariant, QStringLiteral("call-img")),
|
||||
Q_ARG(QVariant, imgs)),
|
||||
QStringLiteral("images a tool made reach the view"));
|
||||
QTest::qWait(150);
|
||||
const QVariantMap card = rowAt(before);
|
||||
check(card.value(QStringLiteral("images"))
|
||||
.toString()
|
||||
.contains(QLatin1String("img-fixture.png")) &&
|
||||
card.value(QStringLiteral("name")).toString() ==
|
||||
QLatin1String("view_image"),
|
||||
QStringLiteral("the card of that call carries the image"));
|
||||
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >
|
||||
thumbsBefore,
|
||||
QStringLiteral("its thumbnail is in the card"));
|
||||
maybeShot(win, QStringLiteral("-toolimage"));
|
||||
}
|
||||
|
||||
/* --- a narrow window: dock -> drawer, and the button opens it --- */
|
||||
resizeTo(win, 460, 900);
|
||||
check(docked && !docked->isVisible(),
|
||||
|
||||
Reference in New Issue
Block a user