From 69842edf16bf6c666c61fa42573ca6dda5d6e8a7 Mon Sep 17 00:00:00 2001 From: WHC Date: Sun, 11 Oct 2026 12:25:33 +0800 Subject: [PATCH] v0.8.10: settings dialog (provider, model, key), window starts on a provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/CMakeLists.txt | 4 +- app/README.md | 29 +++- app/qml/Main.qml | 40 +++++ app/qml/SettingsDialog.qml | 316 +++++++++++++++++++++++++++++++++++++ app/src/core/AgentCore.cpp | 183 +++++++++++++++++++++ app/src/core/AgentCore.h | 24 +++ app/src/main.cpp | 7 +- app/src/ui/UiTest.cpp | 63 ++++++-- include/cst_version.h | 2 +- 9 files changed, 655 insertions(+), 13 deletions(-) create mode 100644 app/qml/SettingsDialog.qml diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 0a9cead..6807e2a 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -103,7 +103,8 @@ if(CST_APP_GUI) # the URI makes Qt register the types with relative URLs ("qmlRegisterType # requires absolute URLs" on every start). foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock - NoteLine TodoPanel Composer ConsentDialog AskDialog Theme) + NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog + Theme) set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml) endforeach() qt_add_qml_module(cst-agent-app @@ -122,6 +123,7 @@ if(CST_APP_GUI) qml/Composer.qml qml/ConsentDialog.qml qml/AskDialog.qml + qml/SettingsDialog.qml qml/Theme.qml) endif() endif() diff --git a/app/README.md b/app/README.md index bf2b854..c43361b 100644 --- a/app/README.md +++ b/app/README.md @@ -122,6 +122,32 @@ creates and deletes sessions. On a machine without a screen, `CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds. +## Provider, model, key + +The sidebar shows where the engine points (`provider · model`); the gear in the +header opens the dialog that changes it. + +- The provider list is the engine's own preset table (`cst_provider_count()`), + with each entry's base URL and where its key would come from: "key saved" + (`.agent`-independent, `/cst-agent/credentials`), "key from + OPENAI_API_KEY", or "no key". A key itself is never sent to the QML side — + the dialog only ever describes it. +- "Use this" calls `AgentCore::useProvider()`, which runs + `cst_ps_switch_provider()` / `cst_ps_set_key()` on the engine's live config: + a pasted key is verified against the provider's `/models` before it is saved + (offline it is kept unverified and the header says so), the model is + remembered in `settings.json`, and the **session is not touched**. Model + candidates come from the provider entry, the fetched cache + (`cst_ps_cached`, no network) and the remembered pick; the label next to the + field is `cst_model_info` ("200K · text only"). +- The dialog also reports the session's token use (`cst_agent_usage`). + +`--uitest` checks it end to end: the presets are readable, an unknown provider +is refused, the gear opens the panel, the provider rows exist, a model typed +into the field reaches the engine when "Use this" is clicked, the note says so, +and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch +directory, so settings and keys of the real user are never rewritten. + ## Tools the app provides A tool implemented in the front end is registered with `cst_tool_register()` @@ -178,7 +204,8 @@ The build flag that keeps Unix helpers out of the picture is blocks, consent/ask dialogs, sidebar, responsive dock/drawer layout, `--uitest` for mouse, touch and small-screen checks - [x] session list in the sidebar: new / switch / delete, transcript replay +- [x] settings dialog: provider, model, key (`cst_ps_*`), token counters - [ ] a real turn driven through the window (needs a provider key) -- [ ] attachments (drag in, paste), settings page (provider, model, key) +- [ ] attachments (drag in, paste) - [ ] Kirigami (Breeze look), tray, global shortcuts - [ ] Android APK (engine in-process, JNI platform tools) diff --git a/app/qml/Main.qml b/app/qml/Main.qml index c73d73b..0988b07 100644 --- a/app/qml/Main.qml +++ b/app/qml/Main.qml @@ -134,6 +134,20 @@ ApplicationWindow { win.reloadSessions() } + // ---- provider, model, key -------------------------------------------- + function openSettings() { + settings.load(agent.providers(), agent.provider, agent.model) + settings.open() + } + + function applySettings(provider, model, key) { + const err = agent.useProvider(provider, model, key) + settings.note = err.length ? err + : "using " + agent.provider + " / " + agent.model + settings.choices = agent.modelChoices(provider) + settings.syncPick() + } + Component.onCompleted: win.reloadSessions() // ---- the shell ------------------------------------------------------- @@ -207,6 +221,25 @@ ApplicationWindow { implicitWidth: 22 implicitHeight: 22 } + ToolButton { + objectName: "settingsButton" + implicitWidth: Theme.touchTarget + implicitHeight: Theme.touchTarget + ToolTip.visible: hovered + ToolTip.text: "Provider, model and key" + contentItem: Text { + text: "⚙" + color: parent.hovered ? Theme.text : Theme.textDim + font.pixelSize: 16 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: Theme.radiusSm + color: parent.hovered ? Theme.surfaceHi : "transparent" + } + onClicked: win.openSettings() + } ToolButton { objectName: "clearButton" enabled: transcript.count > 0 && !agent.running @@ -354,6 +387,13 @@ ApplicationWindow { onAnswered: function (choice) { agent.answerPermission(choice) } } + SettingsDialog { + id: settings + onApplyRequested: function (provider, model, key) { + win.applySettings(provider, model, key) + } + } + AskDialog { id: ask onAnswered: function (text) { agent.answerQuestion(text) } diff --git a/app/qml/SettingsDialog.qml b/app/qml/SettingsDialog.qml new file mode 100644 index 0000000..53bd0fb --- /dev/null +++ b/app/qml/SettingsDialog.qml @@ -0,0 +1,316 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Provider, model and key: what the engine needs before it can talk to a model +// at all. Everything here goes through AgentCore::useProvider(), which moves +// the engine's own config (its session stays where it is) and remembers the +// choice. A key is verified against the provider before it is saved and is +// never read back into this window -- the dialog only ever says where the key +// it will use comes from. +Dialog { + id: dlg + objectName: "settingsDialog" + parent: Overlay.overlay + anchors.centerIn: parent + modal: true + closePolicy: Popup.CloseOnEscape + padding: Theme.pad + + property var providers: [] // AgentCore::providers() + property var choices: [] // AgentCore::modelChoices(picked) + property string picked: "" + property string model: "" + property string note: "" // what the last "Use this" answered + property string keyEnv: "" + property string keyFrom: "" + property bool hasKey: false + + signal applyRequested(string provider, string model, string key) + + width: Math.min(580, parent ? parent.width - 32 : 580) + height: Math.min(560, parent ? parent.height - 60 : 560) + + /* The Basic style paints a light panel; this window is dark, so the + * dialog brings its own surface like every other one here. */ + background: Rectangle { + color: Theme.surface + radius: Theme.radius + border.width: 1 + border.color: Theme.border + } + + /* Fill it in: the presets, the provider the engine is on now, and the + * model that provider last used. */ + function load(list, provider, currentModel) { + providers = list + picked = provider + model = currentModel + note = "" + for (let i = 0; i < list.length; i++) + if (list[i].current === true) + picked = list[i].name + choices = picked.length ? agent.modelChoices(picked) : [] + syncPick() + } + + /* Where the picked provider's key would come from. From the entries -- + * a key itself never reaches the QML side. */ + function syncPick() { + hasKey = false + keyFrom = "" + keyEnv = "" + for (let i = 0; i < providers.length; i++) { + const p = providers[i] + if (p.name !== picked) + continue + hasKey = p.hasKey === true + keyFrom = p.keyFrom + keyEnv = p.keyEnv + return + } + } + + function keyState() { + if (hasKey) + return keyFrom === "saved" ? "a key is saved · paste another to replace it" + : "key from the environment: " + keyFrom + return keyEnv.length ? "no key yet — set " + keyEnv + ", or paste one here" + : "no key yet — paste one here" + } + + function usageLine() { + const u = agent.usage() + if (!u.requests) + return "no tokens spent in this session yet" + let s = u.requests + (u.requests === 1 ? " request" : " requests") + + " · " + u.prompt + " in · " + u.out + " out" + if (u.priced) + s += " · " + u.cost.toFixed(4) + " " + u.currency + return s + } + + contentItem: ColumnLayout { + objectName: "settingsPanel" + spacing: Theme.gap + + Text { + Layout.fillWidth: true + text: "Provider, model, key" + color: Theme.text + font.pixelSize: Theme.bodySize + font.bold: true + } + Text { + Layout.fillWidth: true + text: "Keys are stored under /cst-agent/credentials (mode 0600) and are never shown again." + color: Theme.textFaint + font.pixelSize: Theme.smallSize + wrapMode: Text.Wrap + } + + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 96 + clip: true + contentWidth: width + contentHeight: plist.implicitHeight + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar {} + + ColumnLayout { + id: plist + width: parent.width + spacing: 2 + + Repeater { + model: dlg.providers + delegate: Rectangle { + id: prow + required property var modelData + readonly property bool selected: dlg.picked === modelData.name + + objectName: "providerRow:" + modelData.name + Layout.fillWidth: true + implicitHeight: prowCol.implicitHeight + 8 + radius: Theme.radiusSm + color: prow.selected ? Theme.accentDim + : prowHover.hovered ? Theme.surfaceAlt + : "transparent" + border.width: 1 + border.color: prow.selected ? Theme.accent : "transparent" + + HoverHandler { id: prowHover } + MouseArea { + anchors.fill: parent + onClicked: { + dlg.picked = prow.modelData.name + modelField.text = prow.modelData.model + dlg.choices = agent.modelChoices(prow.modelData.name) + dlg.syncPick() + } + } + + ColumnLayout { + id: prowCol + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: 4 + } + spacing: 0 + + RowLayout { + Layout.fillWidth: true + spacing: Theme.gap + Text { + text: prow.modelData.name + color: prow.selected ? Theme.text : Theme.textDim + font.pixelSize: Theme.smallSize + font.bold: prow.selected + } + Text { + visible: prow.modelData.current === true + text: "in use" + color: Theme.ok + font.pixelSize: Theme.smallSize + } + Item { Layout.fillWidth: true } + Text { + text: prow.modelData.source + color: Theme.textFaint + font.pixelSize: Theme.smallSize + } + } + Text { + Layout.fillWidth: true + elide: Text.ElideMiddle + text: prow.modelData.base + " · " + + (prow.modelData.hasKey === true + ? (prow.modelData.keyFrom === "saved" + ? "key saved" + : "key from " + prow.modelData.keyFrom) + : (prow.modelData.keyEnv.length + ? "no key (" + prow.modelData.keyEnv + ")" + : "no key")) + color: Theme.textFaint + font.pixelSize: Theme.smallSize + } + } + } + } + } + } + + Text { + Layout.fillWidth: true + text: "Model" + color: Theme.textDim + font.pixelSize: Theme.smallSize + font.bold: true + } + RowLayout { + Layout.fillWidth: true + spacing: Theme.gap + TextField { + id: modelField + objectName: "settingsModel" + Layout.fillWidth: true + text: dlg.model + implicitHeight: Theme.touchTarget + selectByMouse: true + placeholderText: "model name" + inputMethodHints: Qt.ImhNoPredictiveText + } + Text { + Layout.preferredWidth: 90 + elide: Text.ElideRight + text: modelField.text.length ? agent.modelInfo(modelField.text) : "" + color: Theme.textFaint + font.pixelSize: Theme.smallSize + } + } + Flow { + Layout.fillWidth: true + spacing: 6 + visible: dlg.choices.length > 0 + + Repeater { + model: dlg.choices + delegate: Rectangle { + id: chip + required property var modelData + readonly property string name: String(chip.modelData) + + objectName: "modelChoice:" + chip.name + implicitWidth: chipText.implicitWidth + 14 + implicitHeight: 26 + radius: Theme.radiusSm + color: chipHover.hovered ? Theme.surfaceHi : "transparent" + border.width: 1 + border.color: modelField.text === chip.name ? Theme.accent + : Theme.border + HoverHandler { id: chipHover } + Text { + id: chipText + anchors.centerIn: parent + text: chip.name + color: modelField.text === chip.name ? Theme.text + : Theme.textDim + font.pixelSize: Theme.smallSize + } + TapHandler { onTapped: modelField.text = chip.name } + } + } + } + + TextField { + id: keyField + objectName: "settingsKey" + Layout.fillWidth: true + implicitHeight: Theme.touchTarget + echoMode: TextInput.Password + selectByMouse: true + placeholderText: "API key (leave empty to keep what is there)" + inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData + onAccepted: dlg.applyRequested(dlg.picked, modelField.text, keyField.text) + } + Text { + Layout.fillWidth: true + text: dlg.keyState() + color: Theme.textFaint + font.pixelSize: Theme.smallSize + wrapMode: Text.Wrap + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.gap + Text { + objectName: "settingsNote" + Layout.fillWidth: true + text: dlg.note.length ? dlg.note : dlg.usageLine() + color: dlg.note.length ? Theme.warn : Theme.textFaint + font.pixelSize: Theme.smallSize + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + Button { + objectName: "settingsClose" + text: "Close" + implicitHeight: Theme.touchTarget + onClicked: dlg.close() + } + Button { + objectName: "settingsApply" + text: "Use this" + highlighted: true + implicitHeight: Theme.touchTarget + onClicked: dlg.applyRequested(dlg.picked, modelField.text, keyField.text) + } + } + } +} diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp index 099fe4a..4f1ba4f 100644 --- a/app/src/core/AgentCore.cpp +++ b/app/src/core/AgentCore.cpp @@ -12,6 +12,9 @@ #include +#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(session.prompt); + m["out"] = static_cast(session.out); + m["cached"] = static_cast(session.cached); + m["cost"] = session.cost; + m["currency"] = utf8(session.currency); + m["priced"] = session.priced != 0; + m["turnPrompt"] = static_cast(turn.prompt); + m["turnOut"] = static_cast(turn.out); + return m; +} + /* --- tools the front end provides ---------------------------------------- */ bool AgentCore::registerTool(const cst_tool_def_t *def) { diff --git a/app/src/core/AgentCore.h b/app/src/core/AgentCore.h index caf5715..2ddf0a8 100644 --- a/app/src/core/AgentCore.h +++ b/app/src/core/AgentCore.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #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(); diff --git a/app/src/main.cpp b/app/src/main.cpp index 50a7c93..4ee1002 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -150,8 +150,13 @@ int main(int argc, char **argv) { std::unique_ptr 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 /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; diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index d057556..533065d 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -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; diff --git a/include/cst_version.h b/include/cst_version.h index 6a9800d..ee2419c 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 9 +#define CST_VERSION_PATCH 10 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x)