v0.8.10: settings dialog (provider, model, key), window starts on a provider

The window could be started, but it could not be pointed anywhere: a provider,
a model and a key only came in on the command line. The gear in the header now
opens a dialog that changes all three on the engine's live config.

AgentCore (app/src/core/AgentCore.*):
- providers(): the engine's preset table (cst_provider_count/at), with base
  URL, the remembered or preset model, whether a key exists and where it comes
  from (cst_creds_resolve: "saved", or the environment variable), the preset's
  key variable and the source ("built-in" / "user"). A key is read into a local
  buffer only to answer "is there one"; it never leaves the C++ side.
- modelChoices(provider): the provider's own list (cst_provider_models), the
  fetched cache (cst_ps_cached -- which writes through its out-parameters, so
  it takes a real int, not NULL: passing one cost a segfault) and the
  remembered pick. No network. modelInfo() is cst_model_info + its "200K ·
  text only" label; usage() is cst_agent_usage for the session and the turn.
- useProvider(provider, model, key) -> "" or a reason: a different provider
  goes through cst_ps_switch_provider() (the key is verified against /models
  before it is saved; offline it is kept unverified and the header says so),
  a key for the current one through cst_ps_set_key(), and the model is
  remembered in settings.json. syncConfig() takes the engine's config back
  over the app's copy, so provider/model/approval read correctly afterwards.
  The engine's session is never touched by a switch.
- open() no longer starts with an empty provider: it takes the one the engine
  used last (settings.json), else the built-in default, plus that provider's
  base URL and model. Without this the first turn could not be sent at all and
  no entry in the dialog was "in use".

QML (app/qml/SettingsDialog.qml, Main.qml):
- a modal dialog with the themed surface the other dialogs use (the Basic
  style's light panel washes out the dark theme), the provider list with its
  key state, a model field with the info label and clickable candidates, a
  password field for a new key, the session's token counters, and a note that
  reports what the last "Use this" answered.

Tests: 8 more checks -- the presets are readable, an unknown provider is
refused, the counters are readable, the gear opens the panel, the rows exist, a
model typed into the field reaches the engine, the note says so, Close puts the
dialog away. 36 checks pass. --uitest also points XDG_CONFIG_HOME at its
scratch directory now, so a run cannot rewrite the user's settings.json or
credentials, and CST_APP_SHOT writes shot.png at the end plus
shot-settings.png while the dialog is open (that is how the dialog above was
reviewed on a machine without a screen).

Verified: 36/36 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of the dialog.

Version 0.8.10.
This commit is contained in:
WHC
2026-10-11 12:25:33 +08:00
parent f5d340bd2a
commit 69842edf16
9 changed files with 655 additions and 13 deletions
+183
View File
@@ -12,6 +12,9 @@
#include <cstdlib>
#include "cst_agent.h"
#include "cst_models.h"
#include "cst_provider_switch.h"
#include "cst_web_md.h"
/* --- the worker thread --------------------------------------------------- */
@@ -76,6 +79,34 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
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. */
@@ -481,6 +512,158 @@ bool AgentCore::renameSession(const QString &id, const QString &title) {
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;
}
/* --- tools the front end provides ---------------------------------------- */
bool AgentCore::registerTool(const cst_tool_def_t *def) {
+24
View File
@@ -13,6 +13,7 @@
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include <QWaitCondition>
#include "cst_agent.h"
@@ -71,6 +72,26 @@ class AgentCore : public QObject {
Q_INVOKABLE bool deleteSession(const QString &id);
Q_INVOKABLE bool renameSession(const QString &id, const QString &title);
/* --- provider, model, key (see cst_provider_switch.h) ------------------- */
/* The presets: name, base, model (remembered, else the preset's), hasKey
* and keyFrom ("saved", else the environment variable), keyEnv, source and
* current. Never a key: the engine keeps those out of the UI's reach. */
Q_INVOKABLE QVariantList providers() const;
/* Model candidates for a provider: its own list, what was fetched into the
* cache, and the remembered pick. No network. */
Q_INVOKABLE QStringList modelChoices(const QString &provider) const;
/* "1M · vision" for the model picker, "" when nothing is known. */
Q_INVOKABLE QString modelInfo(const QString &model) const;
/* Switch provider, model and key in one call. The engine's own config moves
* (its session does not), the choice is remembered, and an empty return
* means it worked; anything else is a reason to show. A key is verified
* against the provider's /models when one is given. */
Q_INVOKABLE QString useProvider(const QString &provider, const QString &model,
const QString &key);
/* Tokens of this session and of the last turn, plus the cost when priced. */
Q_INVOKABLE QVariantMap usage() const;
/* The engine, for the parts that need it (tool registration). */
cst_agent_t *agent() const { return m_agent; }
const cst_agent_config_t *config() const { return &m_cfg; }
@@ -124,6 +145,9 @@ class AgentCore : public QObject {
friend class Turn;
void finishTurn(int rc, const QString &err);
void setRunning(bool on);
/* Take the engine's config over m_cfg again (a switch changed it) and say
* so. */
void syncConfig();
/* Re-read what a session switch changed (id, todo) and say so. */
void afterSessionChange();
+6 -1
View File
@@ -150,8 +150,13 @@ int main(int argc, char **argv) {
std::unique_ptr<QTemporaryDir> scratch;
if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) {
scratch.reset(new QTemporaryDir);
if (scratch->isValid())
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());
}
}
AgentCore core;
+54 -9
View File
@@ -135,6 +135,24 @@ QString writeFixtureSession(const QString &ws, const QString &id) {
return id;
}
/* A picture of an interesting state on request: the only way to look at the
* layout on a machine without a screen (CST_APP_SHOT=shot.png makes the run
* write shot.png at the end and shot-settings.png while the settings dialog
* is open). */
void maybeShot(QQuickWindow *win, const QString &suffix) {
if (!qEnvironmentVariableIsSet("CST_APP_SHOT"))
return;
const QString base = qEnvironmentVariable("CST_APP_SHOT");
const QString path =
suffix.isEmpty() ? base
: base.endsWith(QLatin1String(".png"))
? base.left(base.size() - 4) + suffix + QStringLiteral(".png")
: base + suffix + QStringLiteral(".png");
std::printf("%s %s\n",
win->grabWindow().save(path) ? "note wrote" : "note cannot write",
qPrintable(path));
}
} // namespace
int runUiTest(AgentCore &core, const QString &size) {
@@ -278,6 +296,40 @@ int runUiTest(AgentCore &core, const QString &size) {
check(core.sessionId() == fixture && list && list->property("count").toInt() == 5,
QStringLiteral("switching back replays it again"));
/* --- provider, model, key: the settings dialog ------------------------ */
check(core.providers().size() > 0 &&
!core.providers().first().toMap().value(QStringLiteral("name"))
.toString()
.isEmpty(),
QStringLiteral("the provider presets are readable"));
check(core.useProvider(QStringLiteral("no-such-provider"), QString(), QString())
.contains(QLatin1String("no such provider")),
QStringLiteral("switching to an unknown provider is refused"));
check(core.usage().contains(QStringLiteral("requests")),
QStringLiteral("the token counters are readable"));
clickMouse(win, QStringLiteral("settingsButton"));
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog"));
check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0,
QStringLiteral("the dialog lists the providers"));
maybeShot(win, QStringLiteral("-settings"));
QQuickItem *mfield = item(win, QStringLiteral("settingsModel"));
if (mfield && panel) {
mfield->setProperty("text", QStringLiteral("cst-test-model"));
clickMouse(win, QStringLiteral("settingsApply"));
check(core.model() == QLatin1String("cst-test-model"),
QStringLiteral("the model picked in the dialog reaches the engine"));
QQuickItem *note = item(win, QStringLiteral("settingsNote"));
check(note && note->property("text").toString().contains(
QLatin1String("cst-test-model")),
QStringLiteral("the dialog says what it is using now"));
clickMouse(win, QStringLiteral("settingsClose"));
check(!panel->isVisible(), QStringLiteral("Close puts the dialog away"));
} else {
check(false, QStringLiteral("the dialog has a model field"));
}
/* --- a narrow window: dock -> drawer, and the button opens it --- */
resizeTo(win, 460, 900);
check(docked && !docked->isVisible(),
@@ -295,15 +347,8 @@ int runUiTest(AgentCore &core, const QString &size) {
check(docked && docked->isVisible(),
QStringLiteral("widening again docks the sidebar back"));
/* A picture of the last state on request: the only way to look at the
* layout on a machine without a screen
* (CST_APP_SHOT=shot.png cst-agent-app --uitest). */
if (qEnvironmentVariableIsSet("CST_APP_SHOT")) {
const QString shot = qEnvironmentVariable("CST_APP_SHOT");
std::printf("%s %s\n",
win->grabWindow().save(shot) ? "note wrote" : "note cannot write",
qPrintable(shot));
}
/* A picture of the last state on request (see maybeShot). */
maybeShot(win, QString());
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
return g_fail ? 1 : 0;