forked from WHC/Hermit
Two things the window got wrong once a real turn ran through it, plus the switch that was invisible on a dark theme. Tool cards (app/src/core/AgentCore.*, app/qml/Main.qml, ToolCard.qml): - A turn that called the same tool twice left the first card on "running" forever and gave its result to the second card: both were paired by name. cst_agent_hooks_t hands the call (ctl_tool_call_t) to every callback, so the id travels with the signals now (toolEvent(phase, id, name, detail), toolResult(id, name, exitCode, output)) and findTool() matches on it, in the same way AgentCore::history() fills it from tool_call_id / tool_calls_json for a replayed session. Only a call without an id -- an old text-protocol session -- still falls back to the name. - Nothing may keep spinning after a turn: closeAllRows() runs when the turn ends and settles every open row; a call that never came back shows "no result" (state "unfinished") and no longer claims an exit code. Streaming text (app/qml/Main.qml, ChatView.qml): - The answer was plain text while it streamed and turned into markdown only when the turn ended. A 120 ms timer now re-renders the open answer with the engine's own renderer (AgentCore::renderMarkdown -> cst_web_md_html), so a list, a table or a code fence fills in formatted as it arrives. A row keeps its own "html" field; a replayed session renders once, where its rows are built. QML details: - the Plan mode Switch got a drawn indicator: the Basic style's pill is a dark pill with a dark knob on this theme, which made the control look disabled. - Every engine signal handler is a plain window function now (engineToolEvent, engineToolResult, engineTurnFinished, engineAssistant, ...), which is also what lets --uitest drive the exact path; rowJson(i) returns a transcript row as JSON for it (an object would not survive QMetaObject::invokeMethod). Tests: 6 more checks -- a call opens a card, two calls of one tool are two cards, the result lands on the card of its own call, the other card is untouched, the turn end settles an open call, a delta opens a streaming answer and markdown is rendered while it still streams. 52/52 pass, --selftest, ctest cst-app-selftest. Version 0.8.13.
836 lines
27 KiB
C++
836 lines
27 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 "cst_agent.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_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);
|
|
}
|
|
}
|
|
/* 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 --------------------------------------------------------------- */
|
|
|
|
void AgentCore::send(const QString &prompt) {
|
|
if (!m_agent || m_running || prompt.trimmed().isEmpty())
|
|
return;
|
|
setRunning(true);
|
|
m_turn = new Turn(this, prompt);
|
|
m_turn->start();
|
|
}
|
|
|
|
void AgentCore::cancel() {
|
|
if (m_agent)
|
|
cst_agent_request_cancel(m_agent);
|
|
}
|
|
|
|
void AgentCore::finishTurn(int rc, const QString &err) {
|
|
Turn *t = static_cast<Turn *>(sender());
|
|
post([this, rc, err] {
|
|
setRunning(false);
|
|
/* The prompt carried whatever was pending; the engine dropped it. */
|
|
m_attachNames.clear();
|
|
emit pendingChanged();
|
|
emit turnFinished(rc, err);
|
|
});
|
|
if (t) {
|
|
t->wait();
|
|
t->deleteLater();
|
|
}
|
|
m_turn = nullptr;
|
|
}
|
|
|
|
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;
|
|
QMutexLocker lock(&self->m_askMu);
|
|
self->m_permAnswer = CST_PERM_DENY;
|
|
self->m_permPending = true;
|
|
const QString t = QString::fromUtf8(tool ? tool : "");
|
|
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); });
|
|
while (self->m_permPending && !self->m_closing)
|
|
self->m_askCv.wait(&self->m_askMu);
|
|
self->m_permPending = false;
|
|
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;
|
|
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); });
|
|
while (self->m_questionPending && !self->m_closing)
|
|
self->m_askCv.wait(&self->m_askMu);
|
|
if (self->m_closing)
|
|
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;
|
|
return r;
|
|
}
|
|
|
|
/* 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);
|
|
}
|
|
|
|
QVariantList AgentCore::sessions() const {
|
|
QVariantList out;
|
|
if (m_workspace.isEmpty())
|
|
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();
|
|
for (int i = 0; i < n; i++) {
|
|
cst_session_info_t in;
|
|
memset(&in, 0, sizeof(in));
|
|
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
|
continue;
|
|
const QString id = utf8(in.id[0] ? in.id : ids[i]);
|
|
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 */
|
|
m["current"] = (id == current);
|
|
out.append(m);
|
|
}
|
|
delete[] ids;
|
|
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["state"] = "done";
|
|
r["complete"] = true;
|
|
rows.append(r);
|
|
} else {
|
|
QVariantMap r = rows.at(at).toMap();
|
|
r["output"] = utf8(m->content);
|
|
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);
|
|
if (m->nattach > 0) {
|
|
QStringList names;
|
|
for (int a = 0; a < m->nattach; a++)
|
|
names << utf8(m->attach[a].name);
|
|
text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", "));
|
|
}
|
|
r["text"] = text;
|
|
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())
|
|
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;
|
|
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["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);
|
|
}
|
|
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 ---------------------------------------------------------- */
|
|
|
|
QStringList AgentCore::pendingAttachments() const {
|
|
QStringList out;
|
|
if (!m_agent)
|
|
return out;
|
|
const int n = cst_agent_pending_count(m_agent);
|
|
for (int i = 0; i < n; i++) {
|
|
char line[256] = "";
|
|
if (cst_agent_pending_describe(m_agent, i, line, sizeof(line)) != 0)
|
|
continue;
|
|
/* The engine describes what the model gets ("png 12.0 KB"); the file it
|
|
* came from is the window's own business, so the name goes in front. */
|
|
const QString desc = utf8(line);
|
|
out << (i < m_attachNames.size()
|
|
? QStringLiteral("%1 — %2").arg(m_attachNames.at(i), desc)
|
|
: desc);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
QString AgentCore::attachFile(const QString &path) {
|
|
if (!m_agent)
|
|
return QStringLiteral("no engine");
|
|
if (path.isEmpty())
|
|
return QStringLiteral("no file");
|
|
char err[256] = "";
|
|
if (cst_agent_attach_file(m_agent, path.toUtf8().constData(), err,
|
|
sizeof(err)) != 0)
|
|
return utf8(err[0] ? err : "that file cannot be attached");
|
|
m_attachNames << QFileInfo(path).fileName();
|
|
emit pendingChanged();
|
|
return QString();
|
|
}
|
|
|
|
QString AgentCore::attachClipboard() {
|
|
if (!m_agent)
|
|
return QStringLiteral("no engine");
|
|
QGuiApplication *gui =
|
|
qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
|
if (!gui || !gui->clipboard())
|
|
return QStringLiteral("no clipboard here");
|
|
/* An image is what this is for: text belongs to the editor's own paste. */
|
|
const QImage img = gui->clipboard()->image();
|
|
if (img.isNull())
|
|
return QStringLiteral("no image on the clipboard");
|
|
QByteArray png;
|
|
QBuffer buf(&png);
|
|
buf.open(QIODevice::WriteOnly);
|
|
if (!img.save(&buf, "PNG"))
|
|
return QStringLiteral("cannot encode the clipboard image");
|
|
char err[256] = "";
|
|
if (cst_agent_attach_bytes(m_agent, "clipboard.png", png.constData(),
|
|
static_cast<size_t>(png.size()), err,
|
|
sizeof(err)) != 0)
|
|
return utf8(err[0] ? err : "the clipboard image was refused");
|
|
m_attachNames << QStringLiteral("clipboard.png");
|
|
emit pendingChanged();
|
|
return QString();
|
|
}
|
|
|
|
void AgentCore::clearPending() {
|
|
if (!m_agent)
|
|
return;
|
|
cst_agent_pending_clear(m_agent);
|
|
m_attachNames.clear();
|
|
emit pendingChanged();
|
|
}
|
|
|
|
bool AgentCore::clipboardHasImage() const {
|
|
QGuiApplication *gui =
|
|
qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
|
if (!gui || !gui->clipboard())
|
|
return false;
|
|
return !gui->clipboard()->image().isNull();
|
|
}
|
|
|
|
int AgentCore::seesImages() const {
|
|
return m_agent ? cst_agent_sees_images(m_agent) : 0;
|
|
}
|
|
|
|
/* --- tools the front end provides ---------------------------------------- */
|
|
|
|
bool AgentCore::registerTool(const cst_tool_def_t *def) {
|
|
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); });
|
|
}
|
|
|
|
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::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);
|
|
});
|
|
}
|