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:
+3
-1
@@ -103,7 +103,8 @@ if(CST_APP_GUI)
|
|||||||
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
|
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
|
||||||
# requires absolute URLs" on every start).
|
# requires absolute URLs" on every start).
|
||||||
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
|
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)
|
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
|
||||||
endforeach()
|
endforeach()
|
||||||
qt_add_qml_module(cst-agent-app
|
qt_add_qml_module(cst-agent-app
|
||||||
@@ -122,6 +123,7 @@ if(CST_APP_GUI)
|
|||||||
qml/Composer.qml
|
qml/Composer.qml
|
||||||
qml/ConsentDialog.qml
|
qml/ConsentDialog.qml
|
||||||
qml/AskDialog.qml
|
qml/AskDialog.qml
|
||||||
|
qml/SettingsDialog.qml
|
||||||
qml/Theme.qml)
|
qml/Theme.qml)
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
+28
-1
@@ -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
|
`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.
|
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, `<config>/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
|
## Tools the app provides
|
||||||
|
|
||||||
A tool implemented in the front end is registered with `cst_tool_register()`
|
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,
|
blocks, consent/ask dialogs, sidebar, responsive dock/drawer layout,
|
||||||
`--uitest` for mouse, touch and small-screen checks
|
`--uitest` for mouse, touch and small-screen checks
|
||||||
- [x] session list in the sidebar: new / switch / delete, transcript replay
|
- [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)
|
- [ ] 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
|
- [ ] Kirigami (Breeze look), tray, global shortcuts
|
||||||
- [ ] Android APK (engine in-process, JNI platform tools)
|
- [ ] Android APK (engine in-process, JNI platform tools)
|
||||||
|
|||||||
@@ -134,6 +134,20 @@ ApplicationWindow {
|
|||||||
win.reloadSessions()
|
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()
|
Component.onCompleted: win.reloadSessions()
|
||||||
|
|
||||||
// ---- the shell -------------------------------------------------------
|
// ---- the shell -------------------------------------------------------
|
||||||
@@ -207,6 +221,25 @@ ApplicationWindow {
|
|||||||
implicitWidth: 22
|
implicitWidth: 22
|
||||||
implicitHeight: 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 {
|
ToolButton {
|
||||||
objectName: "clearButton"
|
objectName: "clearButton"
|
||||||
enabled: transcript.count > 0 && !agent.running
|
enabled: transcript.count > 0 && !agent.running
|
||||||
@@ -354,6 +387,13 @@ ApplicationWindow {
|
|||||||
onAnswered: function (choice) { agent.answerPermission(choice) }
|
onAnswered: function (choice) { agent.answerPermission(choice) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsDialog {
|
||||||
|
id: settings
|
||||||
|
onApplyRequested: function (provider, model, key) {
|
||||||
|
win.applySettings(provider, model, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AskDialog {
|
AskDialog {
|
||||||
id: ask
|
id: ask
|
||||||
onAnswered: function (text) { agent.answerQuestion(text) }
|
onAnswered: function (text) { agent.answerQuestion(text) }
|
||||||
|
|||||||
@@ -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 <config>/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
|
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_models.h"
|
||||||
|
#include "cst_provider_switch.h"
|
||||||
#include "cst_web_md.h"
|
#include "cst_web_md.h"
|
||||||
|
|
||||||
/* --- the worker thread --------------------------------------------------- */
|
/* --- the worker thread --------------------------------------------------- */
|
||||||
@@ -76,6 +79,34 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
|
|||||||
if (!apiKey.isEmpty())
|
if (!apiKey.isEmpty())
|
||||||
snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s",
|
snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s",
|
||||||
apiKey.toUtf8().constData());
|
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
|
/* 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
|
* WebUI use. Without this the engine would open session.jsonl in the
|
||||||
* process's working directory, next to a session list that stays empty. */
|
* 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;
|
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 ---------------------------------------- */
|
/* --- tools the front end provides ---------------------------------------- */
|
||||||
|
|
||||||
bool AgentCore::registerTool(const cst_tool_def_t *def) {
|
bool AgentCore::registerTool(const cst_tool_def_t *def) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
|
#include <QVariantMap>
|
||||||
#include <QWaitCondition>
|
#include <QWaitCondition>
|
||||||
|
|
||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
@@ -71,6 +72,26 @@ class AgentCore : public QObject {
|
|||||||
Q_INVOKABLE bool deleteSession(const QString &id);
|
Q_INVOKABLE bool deleteSession(const QString &id);
|
||||||
Q_INVOKABLE bool renameSession(const QString &id, const QString &title);
|
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). */
|
/* The engine, for the parts that need it (tool registration). */
|
||||||
cst_agent_t *agent() const { return m_agent; }
|
cst_agent_t *agent() const { return m_agent; }
|
||||||
const cst_agent_config_t *config() const { return &m_cfg; }
|
const cst_agent_config_t *config() const { return &m_cfg; }
|
||||||
@@ -124,6 +145,9 @@ class AgentCore : public QObject {
|
|||||||
friend class Turn;
|
friend class Turn;
|
||||||
void finishTurn(int rc, const QString &err);
|
void finishTurn(int rc, const QString &err);
|
||||||
void setRunning(bool on);
|
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. */
|
/* Re-read what a session switch changed (id, todo) and say so. */
|
||||||
void afterSessionChange();
|
void afterSessionChange();
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -150,8 +150,13 @@ int main(int argc, char **argv) {
|
|||||||
std::unique_ptr<QTemporaryDir> scratch;
|
std::unique_ptr<QTemporaryDir> scratch;
|
||||||
if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) {
|
if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) {
|
||||||
scratch.reset(new QTemporaryDir);
|
scratch.reset(new QTemporaryDir);
|
||||||
if (scratch->isValid())
|
if (scratch->isValid()) {
|
||||||
ws = scratch->path();
|
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;
|
AgentCore core;
|
||||||
|
|||||||
+54
-9
@@ -135,6 +135,24 @@ QString writeFixtureSession(const QString &ws, const QString &id) {
|
|||||||
return 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
|
} // namespace
|
||||||
|
|
||||||
int runUiTest(AgentCore &core, const QString &size) {
|
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,
|
check(core.sessionId() == fixture && list && list->property("count").toInt() == 5,
|
||||||
QStringLiteral("switching back replays it again"));
|
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 --- */
|
/* --- a narrow window: dock -> drawer, and the button opens it --- */
|
||||||
resizeTo(win, 460, 900);
|
resizeTo(win, 460, 900);
|
||||||
check(docked && !docked->isVisible(),
|
check(docked && !docked->isVisible(),
|
||||||
@@ -295,15 +347,8 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
check(docked && docked->isVisible(),
|
check(docked && docked->isVisible(),
|
||||||
QStringLiteral("widening again docks the sidebar back"));
|
QStringLiteral("widening again docks the sidebar back"));
|
||||||
|
|
||||||
/* A picture of the last state on request: the only way to look at the
|
/* A picture of the last state on request (see maybeShot). */
|
||||||
* layout on a machine without a screen
|
maybeShot(win, QString());
|
||||||
* (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));
|
|
||||||
}
|
|
||||||
|
|
||||||
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
|
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
#define CST_VERSION_MAJOR 0
|
#define CST_VERSION_MAJOR 0
|
||||||
#define CST_VERSION_MINOR 8
|
#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) #x
|
||||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||||
|
|||||||
Reference in New Issue
Block a user