- consent: on_permission / on_ask_user wait at most two / five minutes, and in
the background (a phone off screen, a window that is gone) the answer is
refused at once with a reason -- a dialog nobody can see blocked the worker
for good, which is what "the app hangs while the agent works" looked like.
- finishTurn() no longer joins the thread it is called from; the event loop
reaps the Turn instead.
- AndroidTools: the hop to the GUI thread pings first and gives up after 3 s
instead of waiting forever on a busy GUI thread.
- the session list stopped reading every transcript: cst_agent_store_info()
counts newlines in 64 KiB blocks (first_user only within the first 256 KiB),
and AgentCore::sessions() reuses a row while (mtime, size) is unchanged.
- session_search gets a budget (max_bytes 32 MB, max_seconds 5 s), skips lines
over 64 KiB, and says when the budget stopped the walk.
- the header shows the phase ("shell: make -j8"), so a long turn no longer
looks like a stuck one.
- md_to_pdf: the front end's own Qt tool (owner app:qt, every platform):
markdown or a workspace .md/.html becomes an A4 PDF with 15 mm margins,
paginated by hand, written into the workspace and delivered as a file card.
No PrintSupport, so the phone gets it too.
- --uitest runs md_to_pdf through the registry with Chinese text and checks the
bytes are a PDF; app/CMakeLists.txt no longer drops src/web_md.c from the
standalone engine build, which the window needs to render markdown.
Version 0.9.7.
1289 lines
45 KiB
C++
1289 lines
45 KiB
C++
#include "AgentCore.h"
|
|
|
|
#include <QBuffer>
|
|
#include <QClipboard>
|
|
#include <QDebug>
|
|
#include <QDir>
|
|
#include <QFileInfo>
|
|
#include <QGuiApplication>
|
|
#include <QImage>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QStringList>
|
|
#include <QThread>
|
|
#include <QVariantMap>
|
|
|
|
#include <cstdlib>
|
|
|
|
#include "ToolProvider.h" /* openLocalFile / shareLocalFile */
|
|
#include "cst_agent.h"
|
|
#include "cst_attach.h"
|
|
#include "cst_models.h"
|
|
#include "cst_provider_switch.h"
|
|
#include "cst_web_md.h"
|
|
|
|
/* --- the worker thread --------------------------------------------------- */
|
|
|
|
/* One turn: the whole cst_agent_run_turn_ex() call, on its own thread. The
|
|
* prompt is kept here so the pointer stays valid for the run. */
|
|
class Turn : public QThread {
|
|
public:
|
|
Turn(AgentCore *owner, const QString &prompt)
|
|
: QThread(owner), m_owner(owner), m_prompt(prompt.toUtf8()) {}
|
|
|
|
protected:
|
|
void run() override {
|
|
char err[512] = "";
|
|
int rc = cst_agent_run_turn_ex(m_owner->m_agent, m_prompt.constData(),
|
|
&m_owner->m_hooks, err, sizeof(err));
|
|
m_owner->finishTurn(rc, QString::fromUtf8(err));
|
|
}
|
|
|
|
private:
|
|
AgentCore *m_owner;
|
|
QByteArray m_prompt;
|
|
};
|
|
|
|
/* --- lifetime ------------------------------------------------------------ */
|
|
|
|
AgentCore::AgentCore(QObject *parent) : QObject(parent) {
|
|
memset(&m_cfg, 0, sizeof(m_cfg));
|
|
memset(&m_hooks, 0, sizeof(m_hooks));
|
|
m_hooks.on_status = hookStatus;
|
|
m_hooks.on_assistant_delta = hookAssistant;
|
|
m_hooks.on_think_delta = hookThink;
|
|
m_hooks.on_tool_event = hookToolEvent;
|
|
m_hooks.on_tool_result = hookToolResult;
|
|
m_hooks.on_tool_images = hookToolImages;
|
|
m_hooks.on_todo = hookTodo;
|
|
m_hooks.on_permission = hookPermission;
|
|
m_hooks.on_ask_user = hookAskUser;
|
|
m_hooks.userdata = this;
|
|
}
|
|
|
|
AgentCore::~AgentCore() { close(); }
|
|
|
|
bool AgentCore::open(const QString &workspace, const QString &provider,
|
|
const QString &model, const QString &apiKey,
|
|
QString *err) {
|
|
close();
|
|
QDir dir(workspace);
|
|
if (!dir.exists() && !dir.mkpath(".")) {
|
|
if (err)
|
|
*err = QStringLiteral("cannot create %1").arg(workspace);
|
|
return false;
|
|
}
|
|
const QByteArray ws = QFileInfo(workspace).absoluteFilePath().toUtf8();
|
|
snprintf(m_cfg.workspace_root, sizeof(m_cfg.workspace_root), "%s",
|
|
ws.constData());
|
|
m_workspace = QString::fromUtf8(ws);
|
|
if (!provider.isEmpty())
|
|
snprintf(m_cfg.provider, sizeof(m_cfg.provider), "%s",
|
|
provider.toUtf8().constData());
|
|
if (!model.isEmpty())
|
|
snprintf(m_cfg.model, sizeof(m_cfg.model), "%s", model.toUtf8().constData());
|
|
if (!apiKey.isEmpty())
|
|
snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s",
|
|
apiKey.toUtf8().constData());
|
|
/* A window with no provider cannot do anything: fall back to the one the
|
|
* engine used last, then to its built-in default, and take that provider's
|
|
* base URL and model with it (a --model still wins). */
|
|
cst_settings_t st;
|
|
memset(&st, 0, sizeof(st));
|
|
const bool have_settings = cst_settings_load(&st) == 0;
|
|
if (m_cfg.provider[0] == 0) {
|
|
const char *pick = (have_settings && st.provider[0])
|
|
? st.provider
|
|
: (cst_provider_default()
|
|
? cst_provider_default()->name
|
|
: "");
|
|
if (pick && pick[0])
|
|
snprintf(m_cfg.provider, sizeof(m_cfg.provider), "%s", pick);
|
|
}
|
|
const cst_provider_t *preset = cst_provider_find(m_cfg.provider);
|
|
if (preset) {
|
|
if (m_cfg.api_base[0] == 0 && preset->api_base)
|
|
snprintf(m_cfg.api_base, sizeof(m_cfg.api_base), "%s", preset->api_base);
|
|
if (m_cfg.model[0] == 0) {
|
|
const char *remembered =
|
|
have_settings ? cst_settings_model(&st, m_cfg.provider) : nullptr;
|
|
const char *m =
|
|
(remembered && remembered[0]) ? remembered : preset->model;
|
|
if (m && m[0])
|
|
snprintf(m_cfg.model, sizeof(m_cfg.model), "%s", m);
|
|
}
|
|
}
|
|
/* The window is started without --api-key, and the engine's config never
|
|
* resolves credentials on its own: without this the key the user saved for
|
|
* the provider would be in the file but not in the turn, and every start
|
|
* would ask for it again. Same order as the CLI (main.c): a key passed to
|
|
* open() > the saved one > the provider's env var > CST_API_KEY. */
|
|
if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) {
|
|
char key[512] = "";
|
|
if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) {
|
|
const char *env = getenv("CST_API_KEY");
|
|
if (env && env[0])
|
|
snprintf(key, sizeof(key), "%s", env);
|
|
}
|
|
if (key[0])
|
|
snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s", key);
|
|
memset(key, 0, sizeof(key)); /* never let it outlive the call */
|
|
}
|
|
/* Sessions live in the workspace's store -- the same files the CLI and the
|
|
* WebUI use. Without this the engine would open session.jsonl in the
|
|
* process's working directory, next to a session list that stays empty. */
|
|
char sid[128] = "";
|
|
if (cst_agent_store_resolve_default(m_cfg.workspace_root, m_cfg.session_path,
|
|
sizeof(m_cfg.session_path), sid,
|
|
sizeof(sid)) != 0)
|
|
m_cfg.session_path[0] = 0; /* fall back to the engine's default */
|
|
/* The GUI answers itself: never let the engine decide behind its back. */
|
|
m_cfg.auto_approve = 0;
|
|
m_cfg.headless = 0;
|
|
if (!m_cfg.mode[0])
|
|
snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", CST_DEFAULT_MODE);
|
|
m_agent = cst_agent_create(&m_cfg);
|
|
if (!m_agent) {
|
|
if (err)
|
|
*err = QStringLiteral("cannot start the engine on %1").arg(m_workspace);
|
|
return false;
|
|
}
|
|
/* The session file is created by the engine; attachments live next to it. */
|
|
emit workspaceChanged();
|
|
emit configChanged();
|
|
return true;
|
|
}
|
|
|
|
/* The engine already renders markdown for the WebUI; the window uses the same
|
|
* renderer, so a table or a code fence looks the same in both front ends. */
|
|
QString AgentCore::renderMarkdown(const QString &text) const {
|
|
const QByteArray in = text.toUtf8();
|
|
char *html = cst_web_md_html(in.constData(), static_cast<size_t>(in.size()));
|
|
if (!html)
|
|
return text.toHtmlEscaped().replace(QLatin1Char('\n'),
|
|
QStringLiteral("<br>"));
|
|
const QString out = QString::fromUtf8(html);
|
|
free(html);
|
|
return out;
|
|
}
|
|
|
|
void AgentCore::close() {
|
|
if (!m_agent)
|
|
return;
|
|
if (m_running) {
|
|
cst_agent_request_cancel(m_agent);
|
|
answerPermission(CST_PERM_DENY);
|
|
if (m_turn)
|
|
m_turn->wait(15000);
|
|
}
|
|
m_closing = true;
|
|
cst_agent_destroy(m_agent);
|
|
m_agent = nullptr;
|
|
m_closing = false;
|
|
setRunning(false);
|
|
m_workspace.clear();
|
|
emit workspaceChanged();
|
|
}
|
|
|
|
/* --- turns --------------------------------------------------------------- */
|
|
|
|
namespace {
|
|
/* How long the worker waits for a person. Long enough to read a file diff and
|
|
* decide, short enough that a missed dialog (or a screen that went dark) does
|
|
* not leave the turn waiting for good. */
|
|
constexpr int kConsentTimeoutMs = 2 * 60 * 1000;
|
|
constexpr int kQuestionTimeoutMs = 5 * 60 * 1000;
|
|
|
|
/* The first line of a prompt, cut short: the line the phone's "working"
|
|
* notification carries while the turn runs (app::holdForeground). */
|
|
QString firstLine(const QString &s) {
|
|
const int nl = s.indexOf(QLatin1Char('\n'));
|
|
QString line = (nl < 0 ? s : s.left(nl)).trimmed();
|
|
if (line.size() > 120)
|
|
line = line.left(119) + QStringLiteral("…");
|
|
return line;
|
|
}
|
|
} // namespace
|
|
|
|
void AgentCore::send(const QString &prompt) {
|
|
if (!m_agent || m_running || prompt.trimmed().isEmpty())
|
|
return;
|
|
/* The prompt takes the pending images with it: the view shows them on the
|
|
* message the composer just appended, before the turn can say anything. */
|
|
const QString sending = pendingImages();
|
|
setRunning(true);
|
|
/* A turn can take minutes: hold the process while it runs, or a phone
|
|
* freezes the app the moment it leaves the screen. "" on a desktop and
|
|
* whenever the hold is in place. */
|
|
const QString held = app::holdForeground(firstLine(prompt));
|
|
if (!held.isEmpty())
|
|
emit statusChanged(held);
|
|
if (!sending.isEmpty())
|
|
emit promptImages(sending);
|
|
m_turn = new Turn(this, prompt);
|
|
m_turn->start();
|
|
}
|
|
|
|
void AgentCore::cancel() {
|
|
if (m_agent)
|
|
cst_agent_request_cancel(m_agent);
|
|
}
|
|
|
|
/* Qt suspends the application when the phone puts it away, and a desktop
|
|
* window can be gone too (--uitest runs headless). Either way a consent
|
|
* dialog would be invisible. */
|
|
bool AgentCore::backgrounded() const {
|
|
QGuiApplication *gui =
|
|
qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
|
if (!gui)
|
|
return false;
|
|
if (gui->applicationState() == Qt::ApplicationSuspended)
|
|
return true;
|
|
return gui->allWindows().isEmpty() && gui->topLevelWindows().isEmpty();
|
|
}
|
|
|
|
void AgentCore::finishTurn(int rc, const QString &err) {
|
|
Turn *t = static_cast<Turn *>(sender());
|
|
if (t) {
|
|
/* run() is the caller: waiting for the thread here would be waiting for
|
|
* this very call to return. Let the event loop reap it instead. */
|
|
connect(t, &QThread::finished, t, &QObject::deleteLater);
|
|
}
|
|
post([this, rc, err] {
|
|
m_turn = nullptr;
|
|
setRunning(false);
|
|
app::releaseForeground(); /* the turn is over: let the phone sleep again */
|
|
/* The prompt carried whatever was pending; the engine dropped it. */
|
|
m_attachNames.clear();
|
|
emit pendingChanged();
|
|
emit turnFinished(rc, err);
|
|
});
|
|
}
|
|
|
|
void AgentCore::setRunning(bool on) {
|
|
if (m_running == on)
|
|
return;
|
|
m_running = on;
|
|
emit runningChanged();
|
|
}
|
|
|
|
/* --- consent ------------------------------------------------------------- */
|
|
|
|
void AgentCore::answerPermission(int choice) {
|
|
QMutexLocker lock(&m_askMu);
|
|
if (!m_permPending)
|
|
return;
|
|
m_permAnswer = choice;
|
|
m_permPending = false;
|
|
m_askCv.wakeAll();
|
|
}
|
|
|
|
void AgentCore::answerQuestion(const QString &answer) {
|
|
QMutexLocker lock(&m_askMu);
|
|
if (!m_questionPending)
|
|
return;
|
|
m_answer = answer.toUtf8();
|
|
m_questionPending = false;
|
|
m_askCv.wakeAll();
|
|
}
|
|
|
|
int AgentCore::hookPermission(const char *tool, const char *detail,
|
|
const char *preview, void *ud) {
|
|
AgentCore *self = static_cast<AgentCore *>(ud);
|
|
if (!self || self->m_closing)
|
|
return CST_PERM_DENY;
|
|
if (self->m_autoApprove)
|
|
return CST_PERM_ONCE;
|
|
const QString t = QString::fromUtf8(tool ? tool : "");
|
|
/* The turn survives the app leaving the screen (v0.9.4), so a question
|
|
* asked there would block the worker on a dialog nobody can see -- which is
|
|
* what "the app hangs while the agent works" looks like. In the background
|
|
* the answer is no, with a line the person finds in the transcript. */
|
|
if (self->backgrounded()) {
|
|
self->post([self, t] {
|
|
emit self->permissionExpired(t);
|
|
emit self->statusChanged(
|
|
QStringLiteral("%1: refused — the app was in the background").arg(t));
|
|
});
|
|
return CST_PERM_DENY;
|
|
}
|
|
QMutexLocker lock(&self->m_askMu);
|
|
self->m_permAnswer = CST_PERM_DENY;
|
|
self->m_permPending = true;
|
|
const QString d = QString::fromUtf8(detail ? detail : "");
|
|
const QString p = QString::fromUtf8(preview ? preview : "");
|
|
self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); });
|
|
/* And even in the foreground nobody may hang forever: a dialog can be
|
|
* missed, a screen can go dark. Two minutes, then the answer is no. */
|
|
bool expired = false;
|
|
while (self->m_permPending && !self->m_closing) {
|
|
if (!self->m_askCv.wait(&self->m_askMu, kConsentTimeoutMs)) {
|
|
expired = true;
|
|
break;
|
|
}
|
|
}
|
|
self->m_permPending = false;
|
|
if (expired) {
|
|
self->post([self, t] {
|
|
emit self->permissionExpired(t);
|
|
emit self->statusChanged(
|
|
QStringLiteral("%1: no answer for two minutes — refused").arg(t));
|
|
});
|
|
return CST_PERM_DENY;
|
|
}
|
|
return self->m_permAnswer;
|
|
}
|
|
|
|
int AgentCore::hookAskUser(const char *question, const char *options, char *ans,
|
|
size_t ans_sz, void *ud) {
|
|
AgentCore *self = static_cast<AgentCore *>(ud);
|
|
if (!self || self->m_closing)
|
|
return -1;
|
|
if (self->m_autoApprove)
|
|
return -1;
|
|
if (self->backgrounded()) {
|
|
self->post([self] {
|
|
emit self->questionExpired();
|
|
emit self->statusChanged(
|
|
QStringLiteral("the question was dropped — the app was in the "
|
|
"background"));
|
|
});
|
|
return -1;
|
|
}
|
|
QMutexLocker lock(&self->m_askMu);
|
|
self->m_questionPending = true;
|
|
self->m_answer.clear();
|
|
const QString q = QString::fromUtf8(question ? question : "");
|
|
const QString o = QString::fromUtf8(options ? options : "");
|
|
self->post([self, q, o] { emit self->questionAsked(q, o); });
|
|
bool expired = false;
|
|
while (self->m_questionPending && !self->m_closing) {
|
|
if (!self->m_askCv.wait(&self->m_askMu, kQuestionTimeoutMs)) {
|
|
expired = true;
|
|
break;
|
|
}
|
|
}
|
|
if (self->m_closing)
|
|
return -1;
|
|
self->m_questionPending = false;
|
|
if (expired) {
|
|
self->post([self] {
|
|
emit self->questionExpired();
|
|
emit self->statusChanged(
|
|
QStringLiteral("no answer for five minutes — the question was "
|
|
"dropped"));
|
|
});
|
|
return -1;
|
|
}
|
|
snprintf(ans, ans_sz, "%s", self->m_answer.constData());
|
|
return 0;
|
|
}
|
|
|
|
/* --- settings ------------------------------------------------------------ */
|
|
|
|
void AgentCore::setMode(const QString &mode) {
|
|
const QByteArray m = mode.toUtf8();
|
|
if (!cst_mode_valid(m.constData()))
|
|
return;
|
|
snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", m.constData());
|
|
if (m_agent)
|
|
snprintf(cst_agent_config_mut(m_agent)->mode,
|
|
sizeof(cst_agent_config_mut(m_agent)->mode), "%s",
|
|
m.constData());
|
|
emit modeChanged();
|
|
}
|
|
|
|
void AgentCore::setApproval(int approval) {
|
|
m_cfg.approval = approval;
|
|
if (m_agent)
|
|
cst_agent_config_mut(m_agent)->approval = approval;
|
|
emit approvalChanged();
|
|
}
|
|
|
|
void AgentCore::setPlanMode(bool on) {
|
|
m_cfg.plan_mode = on ? 1 : 0;
|
|
if (m_agent)
|
|
cst_agent_config_mut(m_agent)->plan_mode = m_cfg.plan_mode;
|
|
emit planModeChanged();
|
|
}
|
|
|
|
/* --- sessions ------------------------------------------------------------ */
|
|
|
|
namespace {
|
|
|
|
QString utf8(const char *s) { return QString::fromUtf8(s ? s : ""); }
|
|
|
|
/* A transcript row in the shape Main.qml appends live (see addRow()). */
|
|
QVariantMap sessionRow(const QString &kind, const QString &role) {
|
|
QVariantMap r;
|
|
r["kind"] = kind;
|
|
r["role"] = role;
|
|
r["text"] = QString();
|
|
r["html"] = QString();
|
|
r["complete"] = false;
|
|
r["name"] = QString();
|
|
r["callId"] = QString();
|
|
r["state"] = QString();
|
|
r["detail"] = QString();
|
|
r["output"] = QString();
|
|
r["exitCode"] = 0;
|
|
r["images"] = QString();
|
|
return r;
|
|
}
|
|
|
|
/* One field of one picture line: names come from the user and from tool
|
|
* output, so a separator may not split a line in two. */
|
|
QString field(const char *s) {
|
|
QString out = utf8(s);
|
|
out.replace(QLatin1Char('|'), QLatin1Char('/'));
|
|
out.replace(QLatin1Char('\n'), QLatin1Char(' '));
|
|
return out;
|
|
}
|
|
|
|
/* The files of one message or tool result, one line each:
|
|
*
|
|
* <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
|
|
*
|
|
* kind is "img" for a picture (a thumbnail the view can show) or "file" for a
|
|
* file the agent delivered (a card with open and share). A list role is not an
|
|
* option: a ListModel keeps what fits in a QVariant of a simple type -- a list
|
|
* reads back as undefined there, and setProperty drops the role -- so the rows
|
|
* carry this text and ImageStrip.qml decodes it. A document the user attached
|
|
* (PDF, text) is named in the message text and stays out of it, and so does
|
|
* anything older sessions wrote. */
|
|
QString filesText(const cst_attachment_t *list, int n) {
|
|
QStringList lines;
|
|
for (int i = 0; list && i < n; i++) {
|
|
const cst_attachment_t *a = &list[i];
|
|
const bool img = cst_attach_is_image(a) != 0;
|
|
if (!img && !a->for_user)
|
|
continue;
|
|
QString line = QStringLiteral("%1|%2|%3|%4|%5|%6|%7x%8");
|
|
line = line.arg(img ? QStringLiteral("img") : QStringLiteral("file"));
|
|
line = line.arg(field(a->rel));
|
|
line = line.arg(field(a->name));
|
|
line = line.arg(field(a->origin));
|
|
line = line.arg(field(a->mime));
|
|
line = line.arg(a->bytes);
|
|
line = line.arg(a->width);
|
|
line = line.arg(a->height);
|
|
lines << line;
|
|
}
|
|
return lines.join(QLatin1Char('\n'));
|
|
}
|
|
|
|
/* A stored file of this session, named the way a file card names it: its full
|
|
* path, or empty when the name is not one this session's store owns. A card
|
|
* hands back a name it got from the engine (filesText), so a name no message
|
|
* carries is not a file of this session at all -- never open it. `title`, when
|
|
* asked for, gets the display name the person knows the file by. */
|
|
QString artifactPathOf(cst_agent_t *agent, const QString &rel, QString *title) {
|
|
if (title)
|
|
title->clear();
|
|
if (!agent)
|
|
return QString();
|
|
const QByteArray r = rel.trimmed().toUtf8();
|
|
if (r.isEmpty() || !cst_attach_rel_ok(r.constData()))
|
|
return QString();
|
|
cst_session_t *s = cst_agent_session(agent);
|
|
if (!s)
|
|
return QString();
|
|
bool mine = false;
|
|
cst_session_lock(s);
|
|
const int n = cst_session_count(s);
|
|
for (int i = 0; !mine && i < n; i++) {
|
|
const cst_message_t *m = cst_session_get(s, i);
|
|
for (int a = 0; m && a < m->nattach; a++) {
|
|
if (strcmp(m->attach[a].rel, r.constData()) != 0)
|
|
continue;
|
|
mine = true;
|
|
if (title)
|
|
*title = utf8(m->attach[a].name);
|
|
break;
|
|
}
|
|
}
|
|
cst_session_unlock(s);
|
|
if (!mine)
|
|
return QString();
|
|
cst_attachment_t a;
|
|
memset(&a, 0, sizeof(a));
|
|
snprintf(a.rel, sizeof(a.rel), "%s", r.constData());
|
|
const char *session = cst_agent_session_path(agent);
|
|
char full[4096] = "";
|
|
if (!session || cst_attach_full_path(session, &a, full, sizeof(full)) != 0)
|
|
return QString();
|
|
return utf8(full);
|
|
}
|
|
|
|
/* The open tool row this result belongs to, -1 when none. Rows are paired by
|
|
* call id first: the same tool may run twice in a turn, and pairing by name
|
|
* would hang the first card on "running" forever. Only a call without an id
|
|
* (an old session written by a text protocol) falls back to the name. */
|
|
int findOpenTool(const QVariantList &rows, const QString &callId,
|
|
const QString &name) {
|
|
for (int i = rows.size() - 1; i >= 0; i--) {
|
|
const QVariantMap r = rows.at(i).toMap();
|
|
if (r.value(QStringLiteral("kind")).toString() != QLatin1String("tool"))
|
|
continue;
|
|
if (r.value(QStringLiteral("complete")).toBool())
|
|
continue;
|
|
const QString id = r.value(QStringLiteral("callId")).toString();
|
|
if (!callId.isEmpty()) {
|
|
if (id == callId)
|
|
return i;
|
|
continue; /* another call: its card may be further back */
|
|
}
|
|
if (!id.isEmpty())
|
|
continue;
|
|
if (r.value(QStringLiteral("name")).toString() == name)
|
|
return i;
|
|
return -1;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
QString AgentCore::sessionId() const {
|
|
if (!m_agent)
|
|
return QString();
|
|
char id[128] = "";
|
|
cst_agent_session_id(m_agent, id, sizeof(id));
|
|
return utf8(id);
|
|
}
|
|
|
|
/* Where the session keeps its images: what image://cstattach/ serves, and
|
|
* what the view asks for when the session changes. */
|
|
QString AgentCore::attachmentDir() const {
|
|
if (!m_agent)
|
|
return QString();
|
|
const char *sess = cst_agent_session_path(m_agent);
|
|
if (!sess || !sess[0])
|
|
return QString();
|
|
char dir[4096] = "";
|
|
if (cst_attach_dir(sess, dir, sizeof(dir)) != 0)
|
|
return QString();
|
|
return utf8(dir);
|
|
}
|
|
|
|
/* A file card's "open": the file goes to whatever the system uses for its
|
|
* type (Android ACTION_VIEW through the FileProvider, a desktop's own
|
|
* application). "" when it went through. */
|
|
QString AgentCore::openArtifact(const QString &rel) {
|
|
QString title;
|
|
const QString path = artifactPathOf(m_agent, rel, &title);
|
|
if (path.isEmpty())
|
|
return QStringLiteral("no such file in this session: %1").arg(rel);
|
|
QString status;
|
|
const QString err = app::openLocalFile(path, title, &status);
|
|
if (!err.isEmpty())
|
|
return err;
|
|
if (!status.isEmpty())
|
|
emit statusChanged(status);
|
|
return QString();
|
|
}
|
|
|
|
/* The same through the share sheet (a phone) or the clipboard (a desktop). */
|
|
QString AgentCore::shareArtifact(const QString &rel) {
|
|
QString title;
|
|
const QString path = artifactPathOf(m_agent, rel, &title);
|
|
if (path.isEmpty())
|
|
return QStringLiteral("no such file in this session: %1").arg(rel);
|
|
QString status;
|
|
const QString err = app::shareLocalFile(path, title, &status);
|
|
if (!err.isEmpty())
|
|
return err;
|
|
if (!status.isEmpty())
|
|
emit statusChanged(status);
|
|
return QString();
|
|
}
|
|
|
|
QVariantList AgentCore::sessions() const {
|
|
QVariantList out;
|
|
if (m_workspace.isEmpty()) {
|
|
m_sessionCache.clear();
|
|
return out;
|
|
}
|
|
const QByteArray ws = m_workspace.toUtf8();
|
|
const int kMax = 200;
|
|
char(*ids)[128] = new char[kMax][128];
|
|
const int n = cst_agent_store_list(ws.constData(), ids, kMax);
|
|
const QString current = sessionId();
|
|
QHash<QString, SessionCache> fresh;
|
|
fresh.reserve(n);
|
|
for (int i = 0; i < n; i++) {
|
|
const QString id = utf8(ids[i]);
|
|
/* Filling one row walks that session's whole transcript, and the sidebar
|
|
* asks for the list on every store change: the transcript's own stat is
|
|
* what tells "nothing happened to this one" from "it grew", so an
|
|
* unchanged session costs a stat instead of a read. */
|
|
char path[4096] = "";
|
|
const bool have_path = cst_agent_store_path_for_id(
|
|
ws.constData(), ids[i], path, sizeof(path)) == 0;
|
|
const QFileInfo fi(have_path ? QString::fromUtf8(path) : QString());
|
|
const qint64 mtime = fi.lastModified().toSecsSinceEpoch();
|
|
const qint64 size = fi.size();
|
|
SessionCache entry;
|
|
const auto cached = m_sessionCache.constFind(id);
|
|
if (cached != m_sessionCache.constEnd() && cached->mtime == mtime &&
|
|
cached->size == size) {
|
|
entry = *cached;
|
|
} else {
|
|
cst_session_info_t in;
|
|
memset(&in, 0, sizeof(in));
|
|
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
|
continue;
|
|
entry.mtime = mtime;
|
|
entry.size = size;
|
|
QVariantMap m;
|
|
m["id"] = id;
|
|
m["title"] = utf8(in.title);
|
|
m["firstUser"] = utf8(in.first_user);
|
|
m["messages"] = in.messages;
|
|
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
|
|
entry.row = m;
|
|
}
|
|
QVariantMap m = entry.row;
|
|
m["current"] = (id == current);
|
|
out.append(m);
|
|
fresh.insert(id, entry);
|
|
}
|
|
delete[] ids;
|
|
m_sessionCache = fresh; /* and the ids that are gone are forgotten */
|
|
return out;
|
|
}
|
|
|
|
QVariantList AgentCore::history() const {
|
|
QVariantList rows;
|
|
cst_session_t *s = m_agent ? cst_agent_session(m_agent) : nullptr;
|
|
if (!s)
|
|
return rows;
|
|
cst_session_lock(s);
|
|
const int n = cst_session_count(s);
|
|
for (int i = 0; i < n; i++) {
|
|
const cst_message_t *m = cst_session_get(s, i);
|
|
if (!m || m->role == CST_ROLE_SYSTEM)
|
|
continue; /* the prompt is the engine's, not the conversation */
|
|
if (m->role == CST_ROLE_TOOL || cst_message_is_tool_result(m)) {
|
|
/* Tool output: role=tool in the native protocol, a user message in the
|
|
* text ones. Both belong on the card of the call they answer. */
|
|
const QString name = utf8(m->tool_name);
|
|
const QString callId = utf8(m->tool_call_id);
|
|
const int at = findOpenTool(rows, callId, name);
|
|
if (at < 0) {
|
|
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
|
|
r["name"] = name;
|
|
r["callId"] = callId;
|
|
r["output"] = utf8(m->content);
|
|
r["images"] = filesText(m->attach, m->nattach);
|
|
r["state"] = "done";
|
|
r["complete"] = true;
|
|
rows.append(r);
|
|
} else {
|
|
QVariantMap r = rows.at(at).toMap();
|
|
r["output"] = utf8(m->content);
|
|
r["images"] = filesText(m->attach, m->nattach);
|
|
r["state"] = "done";
|
|
r["complete"] = true;
|
|
rows[at] = r;
|
|
}
|
|
continue;
|
|
}
|
|
if (cst_message_is_note(m)) {
|
|
QVariantMap r = sessionRow(QStringLiteral("note"), QStringLiteral("engine"));
|
|
r["text"] = utf8(m->content);
|
|
r["complete"] = true;
|
|
rows.append(r);
|
|
continue;
|
|
}
|
|
if (m->role == CST_ROLE_USER) {
|
|
QVariantMap r = sessionRow(QStringLiteral("message"), QStringLiteral("user"));
|
|
QString text = utf8(m->content);
|
|
/* A document keeps its name in the text; a picture is shown instead
|
|
* (the images below), so only the rest is worth naming. */
|
|
QStringList names;
|
|
for (int a = 0; a < m->nattach; a++)
|
|
if (!cst_attach_is_image(&m->attach[a]))
|
|
names << utf8(m->attach[a].name);
|
|
if (!names.isEmpty())
|
|
text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", "));
|
|
r["text"] = text;
|
|
r["images"] = filesText(m->attach, m->nattach);
|
|
r["complete"] = true;
|
|
rows.append(r);
|
|
continue;
|
|
}
|
|
if (m->role != CST_ROLE_ASSISTANT)
|
|
continue;
|
|
if (m->reasoning && m->reasoning[0]) {
|
|
QVariantMap r = sessionRow(QStringLiteral("think"), QStringLiteral("agent"));
|
|
r["text"] = utf8(m->reasoning);
|
|
r["complete"] = true;
|
|
rows.append(r);
|
|
}
|
|
if (m->content && m->content[0]) {
|
|
QVariantMap r = sessionRow(QStringLiteral("message"), QStringLiteral("agent"));
|
|
r["text"] = utf8(m->content);
|
|
r["complete"] = true;
|
|
rows.append(r);
|
|
}
|
|
if (m->tool_calls_json) {
|
|
const QJsonArray calls =
|
|
QJsonDocument::fromJson(QByteArray(m->tool_calls_json)).array();
|
|
for (const QJsonValue &v : calls) {
|
|
const QJsonObject o = v.toObject();
|
|
const QJsonObject fn = o.value(QStringLiteral("function")).toObject();
|
|
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
|
|
r["name"] = fn.value(QStringLiteral("name")).toString();
|
|
r["callId"] = o.value(QStringLiteral("id")).toString();
|
|
r["detail"] = fn.value(QStringLiteral("arguments")).toString();
|
|
r["state"] = "unfinished"; /* until its result shows up below */
|
|
rows.append(r);
|
|
}
|
|
}
|
|
}
|
|
cst_session_unlock(s);
|
|
return rows;
|
|
}
|
|
|
|
void AgentCore::afterSessionChange() {
|
|
const QString todo = utf8(cst_agent_todo(m_agent));
|
|
emit sessionChanged();
|
|
emit sessionListChanged();
|
|
if (todo != m_todo) {
|
|
m_todo = todo;
|
|
emit todoChanged(todo);
|
|
}
|
|
}
|
|
|
|
bool AgentCore::newSession() {
|
|
if (!m_agent || m_running)
|
|
return false;
|
|
char id[128] = "";
|
|
if (cst_agent_new_session(m_agent, id, sizeof(id)) != 0)
|
|
return false;
|
|
afterSessionChange();
|
|
return true;
|
|
}
|
|
|
|
bool AgentCore::switchSession(const QString &id) {
|
|
if (!m_agent || m_running || id.isEmpty())
|
|
return false;
|
|
if (id == sessionId()) {
|
|
/* A click on the row of the session that is already open: nothing to
|
|
* switch, but the window still wants the transcript (the row is not a
|
|
* dead spot), and sessionChanged is what draws it. */
|
|
emit sessionChanged();
|
|
return true;
|
|
}
|
|
if (cst_agent_switch_session(m_agent, id.toUtf8().constData()) != 0)
|
|
return false;
|
|
afterSessionChange();
|
|
return true;
|
|
}
|
|
|
|
bool AgentCore::deleteSession(const QString &id) {
|
|
if (!m_agent || m_running || id.isEmpty() || m_workspace.isEmpty())
|
|
return false;
|
|
const bool wasOpen = (id == sessionId());
|
|
if (wasOpen) {
|
|
/* The engine must not sit on a transcript that is about to vanish: it
|
|
* moves to a fresh session first, exactly like the WebUI does. */
|
|
char fresh[128] = "";
|
|
if (cst_agent_new_session(m_agent, fresh, sizeof(fresh)) != 0)
|
|
return false;
|
|
}
|
|
const QByteArray ws = m_workspace.toUtf8();
|
|
const QByteArray sid = id.toUtf8();
|
|
if (cst_agent_store_delete(ws.constData(), sid.constData()) != 0)
|
|
return false;
|
|
if (wasOpen)
|
|
afterSessionChange();
|
|
else
|
|
emit sessionListChanged();
|
|
return true;
|
|
}
|
|
|
|
bool AgentCore::renameSession(const QString &id, const QString &title) {
|
|
if (id.isEmpty() || m_workspace.isEmpty())
|
|
return false;
|
|
/* A title is not in the transcript, so the cached rows would not notice the
|
|
* rename: drop them and let the next list read them again. */
|
|
m_sessionCache.clear();
|
|
const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(),
|
|
t = title.toUtf8();
|
|
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
|
|
0)
|
|
return false;
|
|
emit sessionListChanged();
|
|
return true;
|
|
}
|
|
|
|
/* --- provider, model, key ------------------------------------------------ */
|
|
|
|
void AgentCore::syncConfig() {
|
|
if (m_agent)
|
|
m_cfg = *cst_agent_config(m_agent);
|
|
emit configChanged();
|
|
}
|
|
|
|
QVariantList AgentCore::providers() const {
|
|
QVariantList out;
|
|
cst_settings_t st;
|
|
memset(&st, 0, sizeof(st));
|
|
const bool have_settings = cst_settings_load(&st) == 0;
|
|
const int n = cst_provider_count();
|
|
for (int i = 0; i < n; i++) {
|
|
const cst_provider_t *p = cst_provider_at(i);
|
|
if (!p || !p->name)
|
|
continue;
|
|
char key[512] = "";
|
|
const char *from = "";
|
|
const bool has_key =
|
|
cst_creds_resolve(p->name, key, sizeof(key), &from) == 0;
|
|
memset(key, 0, sizeof(key)); /* never let it outlive the call */
|
|
const char *remembered =
|
|
have_settings ? cst_settings_model(&st, p->name) : nullptr;
|
|
QVariantMap m;
|
|
m["name"] = utf8(p->name);
|
|
m["base"] = utf8(cst_ps_base(&m_cfg, p->name));
|
|
m["model"] = utf8(remembered && remembered[0]
|
|
? remembered
|
|
: (p->model ? p->model : ""));
|
|
m["hasKey"] = has_key;
|
|
m["keyFrom"] = utf8(from);
|
|
m["keyEnv"] = utf8(p->key_env);
|
|
m["source"] = utf8(cst_provider_source(p));
|
|
m["custom"] = strcmp(cst_provider_source(p), "user") == 0;
|
|
m["current"] = (m_cfg.provider[0] && strcmp(p->name, m_cfg.provider) == 0);
|
|
out.append(m);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
QStringList AgentCore::modelChoices(const QString &provider) const {
|
|
QStringList out;
|
|
if (provider.isEmpty())
|
|
return out;
|
|
const QByteArray name = provider.toUtf8();
|
|
const int kMax = 64;
|
|
char(*ids)[128] = new char[kMax][128];
|
|
|
|
const cst_provider_t *p = cst_provider_find(name.constData());
|
|
if (p) {
|
|
const int n = cst_provider_models(p, ids, kMax);
|
|
for (int i = 0; i < n && i < kMax; i++)
|
|
if (ids[i][0])
|
|
out << utf8(ids[i]);
|
|
}
|
|
int fresh = 0;
|
|
const int cached = cst_ps_cached(name.constData(), ids, kMax, &fresh);
|
|
for (int i = 0; i < cached && i < kMax; i++)
|
|
if (ids[i][0])
|
|
out << utf8(ids[i]);
|
|
delete[] ids;
|
|
|
|
cst_settings_t st;
|
|
memset(&st, 0, sizeof(st));
|
|
if (cst_settings_load(&st) == 0) {
|
|
const char *cur = cst_settings_model(&st, name.constData());
|
|
if (cur && cur[0])
|
|
out.prepend(utf8(cur));
|
|
}
|
|
out.removeDuplicates();
|
|
return out;
|
|
}
|
|
|
|
QString AgentCore::modelInfo(const QString &model) const {
|
|
if (model.isEmpty())
|
|
return QString();
|
|
cst_model_info_t mi;
|
|
memset(&mi, 0, sizeof(mi));
|
|
const QByteArray mdl = model.toUtf8();
|
|
cst_model_info(m_cfg.provider[0] ? m_cfg.provider : nullptr, mdl.constData(),
|
|
&mi);
|
|
char label[64] = "";
|
|
cst_model_info_label(&mi, label, sizeof(label));
|
|
return utf8(label);
|
|
}
|
|
|
|
QString AgentCore::useProvider(const QString &provider, const QString &model,
|
|
const QString &key) {
|
|
if (!m_agent)
|
|
return QStringLiteral("no engine");
|
|
if (m_running)
|
|
return QStringLiteral("a turn is running — stop it first");
|
|
if (provider.isEmpty())
|
|
return QStringLiteral("pick a provider");
|
|
const QByteArray name = provider.toUtf8(), k = key.toUtf8();
|
|
const QByteArray mdl = model.trimmed().toUtf8();
|
|
cst_agent_config_t *cfg = cst_agent_config_mut(m_agent);
|
|
char err[512] = "";
|
|
if (strcmp(name.constData(), cfg->provider) != 0) {
|
|
const int rc = cst_ps_switch_provider(
|
|
cfg, name.constData(), k.isEmpty() ? nullptr : k.constData(), err,
|
|
sizeof(err));
|
|
if (rc < 0)
|
|
return rc == CST_PS_UNKNOWN
|
|
? QStringLiteral("no such provider: %1").arg(provider)
|
|
: rc == CST_PS_NEED_KEY
|
|
? QStringLiteral("no key for %1 yet: paste one here")
|
|
.arg(provider)
|
|
: utf8(err[0] ? err : "the provider was refused");
|
|
if (rc & CST_PS_UNVERIFIED)
|
|
emit statusChanged(QStringLiteral("%1: key saved, not verified (%2)")
|
|
.arg(provider, utf8(err)));
|
|
else if (rc & CST_PS_UNSAVED)
|
|
emit statusChanged(QStringLiteral("%1: the key was not saved (%2)")
|
|
.arg(provider, utf8(err)));
|
|
} else if (!k.isEmpty()) {
|
|
const int rc = cst_ps_set_key(cfg, k.constData(), err, sizeof(err));
|
|
if (rc < 0)
|
|
return utf8(err[0] ? err : "the key was rejected");
|
|
if (rc & CST_PS_UNVERIFIED)
|
|
emit statusChanged(QStringLiteral("key saved, not verified (%1)")
|
|
.arg(utf8(err)));
|
|
}
|
|
if (mdl[0] && strcmp(mdl.constData(), cfg->model) != 0) {
|
|
snprintf(cfg->model, sizeof(cfg->model), "%s", mdl.constData());
|
|
cst_settings_remember(cfg);
|
|
}
|
|
/* A base URL edited in the advanced form (a custom provider, or a preset we
|
|
* override) takes effect without a trip through a different provider. */
|
|
const cst_provider_t *entry = cst_provider_find(name.constData());
|
|
if (entry && entry->api_base && strcmp(entry->api_base, cfg->api_base) != 0)
|
|
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", entry->api_base);
|
|
/* And the generation parameters remembered for whichever provider we ended
|
|
* on (cst_agent_create() does the same for the one it starts with). */
|
|
cst_settings_t st;
|
|
if (cst_settings_load(&st) == 0) {
|
|
const cst_gen_params_t *g = cst_settings_params(&st, cfg->provider);
|
|
cfg->temperature = g ? g->temperature : 0;
|
|
cfg->top_p = g ? g->top_p : 0;
|
|
cfg->max_tokens = g ? g->max_tokens : 0;
|
|
}
|
|
syncConfig();
|
|
return QString();
|
|
}
|
|
|
|
QString AgentCore::addProvider(const QString &name, const QString &base,
|
|
const QString &keyEnv,
|
|
const QString &defaultModel,
|
|
const QString &modelsCsv) {
|
|
const QByteArray n = name.trimmed().toUtf8();
|
|
const QByteArray b = base.trimmed().toUtf8();
|
|
const QByteArray ke = keyEnv.trimmed().toUtf8();
|
|
const QByteArray dm = defaultModel.trimmed().toUtf8();
|
|
/* "a, b, c" -> the model list the engine will offer for it. */
|
|
QVector<QByteArray> list;
|
|
for (const QString &part : modelsCsv.split(QLatin1Char(','), Qt::SkipEmptyParts)) {
|
|
const QByteArray t = part.trimmed().toUtf8();
|
|
if (!t.isEmpty() && list.size() < 32)
|
|
list.append(t);
|
|
}
|
|
QVector<const char *> ptrs;
|
|
for (const QByteArray &p : list)
|
|
ptrs.append(p.constData()); /* list is complete: no reallocation after */
|
|
char err[512] = "";
|
|
if (cst_models_user_set_provider(n.constData(), b.constData(),
|
|
ke.isEmpty() ? nullptr : ke.constData(),
|
|
dm.isEmpty() ? nullptr : dm.constData(),
|
|
ptrs.isEmpty() ? nullptr : ptrs.constData(),
|
|
ptrs.size(), err, sizeof(err)) != 0)
|
|
return utf8(err[0] ? err : "the provider was refused");
|
|
/* Editing the live provider's own entry: its new base URL is live at once. */
|
|
if (m_agent && m_cfg.provider[0] && strcmp(m_cfg.provider, n.constData()) == 0) {
|
|
const cst_provider_t *p = cst_provider_find(n.constData());
|
|
if (p && p->api_base && strcmp(p->api_base, m_cfg.api_base) != 0) {
|
|
cst_agent_config_t *cfg = cst_agent_config_mut(m_agent);
|
|
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
|
|
syncConfig();
|
|
}
|
|
}
|
|
return QString();
|
|
}
|
|
|
|
int AgentCore::removeProvider(const QString &name) {
|
|
const QByteArray n = name.trimmed().toUtf8();
|
|
if (n.isEmpty())
|
|
return -1;
|
|
char err[512] = "";
|
|
const int rc = cst_models_user_drop_provider(n.constData(), err, sizeof(err));
|
|
if (rc < 0)
|
|
emit statusChanged(QStringLiteral("%1: %2")
|
|
.arg(name, utf8(err[0] ? err : "not removed")));
|
|
return rc;
|
|
}
|
|
|
|
QVariantMap AgentCore::genParams(const QString &provider) const {
|
|
QVariantMap m;
|
|
m["temperature"] = 0.0;
|
|
m["top_p"] = 0.0;
|
|
m["max_tokens"] = 0;
|
|
const QByteArray name = provider.trimmed().toUtf8();
|
|
cst_settings_t st;
|
|
if (cst_settings_load(&st) == 0) {
|
|
const cst_gen_params_t *g = cst_settings_params(&st, name.constData());
|
|
if (g) {
|
|
m["temperature"] = g->temperature;
|
|
m["top_p"] = g->top_p;
|
|
m["max_tokens"] = g->max_tokens;
|
|
}
|
|
}
|
|
/* The live provider's config is what actually goes out: show that, not what
|
|
* a file says about it. */
|
|
if (name.size() && m_cfg.provider[0] &&
|
|
strcmp(m_cfg.provider, name.constData()) == 0) {
|
|
m["temperature"] = m_cfg.temperature;
|
|
m["top_p"] = m_cfg.top_p;
|
|
m["max_tokens"] = m_cfg.max_tokens;
|
|
}
|
|
return m;
|
|
}
|
|
|
|
QString AgentCore::setGenParams(const QString &provider, double temperature,
|
|
double topP, int maxTokens) {
|
|
const QByteArray name = provider.trimmed().toUtf8();
|
|
if (name.isEmpty())
|
|
return QStringLiteral("pick a provider first");
|
|
cst_gen_params_t g;
|
|
memset(&g, 0, sizeof(g));
|
|
snprintf(g.provider, sizeof(g.provider), "%s", name.constData());
|
|
g.temperature = temperature > 0 ? temperature : 0;
|
|
g.top_p = topP > 0 ? topP : 0;
|
|
g.max_tokens = maxTokens > 0 ? maxTokens : 0;
|
|
if (cst_settings_remember_params(name.constData(), &g) != 0)
|
|
return QStringLiteral("the parameters were not saved");
|
|
if (m_agent && strcmp(m_cfg.provider, name.constData()) == 0) {
|
|
cst_agent_config_t *cfg = cst_agent_config_mut(m_agent);
|
|
cfg->temperature = g.temperature;
|
|
cfg->top_p = g.top_p;
|
|
cfg->max_tokens = g.max_tokens;
|
|
syncConfig();
|
|
}
|
|
return QString();
|
|
}
|
|
|
|
QVariantMap AgentCore::usage() const {
|
|
QVariantMap m;
|
|
if (!m_agent)
|
|
return m;
|
|
cst_usage_t session, turn;
|
|
memset(&session, 0, sizeof(session));
|
|
memset(&turn, 0, sizeof(turn));
|
|
cst_agent_usage(m_agent, &session, &turn);
|
|
m["requests"] = session.requests;
|
|
m["prompt"] = static_cast<qlonglong>(session.prompt);
|
|
m["out"] = static_cast<qlonglong>(session.out);
|
|
m["cached"] = static_cast<qlonglong>(session.cached);
|
|
m["cost"] = session.cost;
|
|
m["currency"] = utf8(session.currency);
|
|
m["priced"] = session.priced != 0;
|
|
m["turnPrompt"] = static_cast<qlonglong>(turn.prompt);
|
|
m["turnOut"] = static_cast<qlonglong>(turn.out);
|
|
return m;
|
|
}
|
|
|
|
/* --- attachments ---------------------------------------------------------- */
|
|
|
|
/* What the prompt about to be sent carries, ready for the view. The stored
|
|
* name is the engine's (the file already lives next to the session), the
|
|
* display name is the one the window kept when it copied the file in. */
|
|
QString AgentCore::pendingImages() const {
|
|
QStringList lines;
|
|
if (!m_agent)
|
|
return QString();
|
|
const int n = cst_agent_pending_count(m_agent);
|
|
for (int i = 0; i < n; i++) {
|
|
if (!cst_agent_pending_is_image(m_agent, i))
|
|
continue;
|
|
const char *rel = cst_agent_pending_rel(m_agent, i);
|
|
if (!rel || !rel[0])
|
|
continue;
|
|
const QString name =
|
|
i < m_attachNames.size() ? m_attachNames.at(i) : utf8(rel);
|
|
/* The size is not known before the engine stores the file; the strip
|
|
* falls back to a picture's usual shape until it is. */
|
|
lines << QStringLiteral("%1|%2|user|0x0")
|
|
.arg(field(rel), field(name.toUtf8().constData()));
|
|
}
|
|
return lines.join(QLatin1Char('\n'));
|
|
}
|
|
|
|
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");
|
|
/* What a picker hands over is not always a path: on a phone it is a SAF
|
|
* content:// URI, and the app copies that into its own directory first (the
|
|
* engine opens files by path). A desktop passes the path through. */
|
|
QString err2;
|
|
const QString local = app::materializeAttachment(path, &err2);
|
|
if (local.isEmpty())
|
|
return err2.isEmpty() ? QStringLiteral("that file cannot be attached")
|
|
: err2;
|
|
char err[256] = "";
|
|
if (cst_agent_attach_file(m_agent, local.toUtf8().constData(), err,
|
|
sizeof(err)) != 0)
|
|
return utf8(err[0] ? err : "that file cannot be attached");
|
|
m_attachNames << QFileInfo(local).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) {
|
|
if (!def || cst_tool_register(def) != 0)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
void AgentCore::unregisterOwner(const QString &owner) {
|
|
const QByteArray o = owner.toUtf8();
|
|
if (cst_tool_unregister_owner(o.constData()) > 0)
|
|
cst_tool_meta_reload();
|
|
}
|
|
|
|
/* --- hook trampolines ---------------------------------------------------- */
|
|
|
|
void AgentCore::hookStatus(const char *msg, void *ud) {
|
|
AgentCore *self = static_cast<AgentCore *>(ud);
|
|
const QString m = QString::fromUtf8(msg ? msg : "");
|
|
self->post([self, m] { emit self->statusChanged(m); });
|
|
}
|
|
|
|
void AgentCore::hookAssistant(const char *text, size_t len, void *ud) {
|
|
AgentCore *self = static_cast<AgentCore *>(ud);
|
|
const QString t = QString::fromUtf8(QByteArray(text, static_cast<int>(len)));
|
|
self->post([self, t] { emit self->assistantDelta(t); });
|
|
}
|
|
|
|
void AgentCore::hookThink(const char *text, size_t len, void *ud) {
|
|
AgentCore *self = static_cast<AgentCore *>(ud);
|
|
const QString t = QString::fromUtf8(QByteArray(text, static_cast<int>(len)));
|
|
self->post([self, t] { emit self->thinkDelta(t); });
|
|
}
|
|
|
|
void AgentCore::hookToolEvent(const char *phase, const ctl_tool_call_t *call,
|
|
const char *detail, void *ud) {
|
|
AgentCore *self = static_cast<AgentCore *>(ud);
|
|
const QString p = QString::fromUtf8(phase ? phase : "");
|
|
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
|
|
const QString n = QString::fromUtf8(call ? call->name : "");
|
|
const QString d = QString::fromUtf8(detail ? detail : "");
|
|
self->post([self, p, id, n, d] {
|
|
emit self->toolEvent(p, id, n, d);
|
|
/* The header says what the turn is doing right now ("shell: make -j8"),
|
|
* not just that something is happening: a long turn is otherwise
|
|
* indistinguishable from a stuck one. */
|
|
if (p == QLatin1String("done")) {
|
|
emit self->statusChanged(QString());
|
|
return;
|
|
}
|
|
QString what = d;
|
|
if (what.size() > 80)
|
|
what = what.left(79) + QStringLiteral("…");
|
|
emit self->statusChanged(what.isEmpty() ? n : QStringLiteral("%1: %2").arg(n, what));
|
|
});
|
|
}
|
|
|
|
void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
|
|
size_t len, int exit_code, void *ud) {
|
|
AgentCore *self = static_cast<AgentCore *>(ud);
|
|
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
|
|
const QString n = QString::fromUtf8(call ? call->name : "");
|
|
QString o = QString::fromUtf8(QByteArray(out, static_cast<int>(len)));
|
|
if (o.size() > 4000)
|
|
o = o.left(4000) + QStringLiteral("\n… (truncated for the UI)");
|
|
self->post(
|
|
[self, id, n, exit_code, o] { emit self->toolResult(id, n, exit_code, o); });
|
|
}
|
|
|
|
void AgentCore::hookToolImages(const ctl_tool_call_t *call,
|
|
const cst_attachment_t *list, int n, void *ud) {
|
|
AgentCore *self = static_cast<AgentCore *>(ud);
|
|
const QString images = filesText(list, n);
|
|
if (images.isEmpty())
|
|
return; /* a call that made no picture needs no signal */
|
|
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
|
|
self->post([self, id, images] { emit self->toolImages(id, images); });
|
|
}
|
|
|
|
void AgentCore::hookTodo(const char *text, void *ud) {
|
|
AgentCore *self = static_cast<AgentCore *>(ud);
|
|
const QString t = QString::fromUtf8(text ? text : "");
|
|
/* Runs on the worker thread: touch m_todo on the GUI thread only. */
|
|
self->post([self, t] {
|
|
self->m_todo = t;
|
|
emit self->todoChanged(t);
|
|
});
|
|
}
|