v0.8.15: custom providers and model parameters under Advanced options
The settings dialog could pick a preset, a model and a key and nothing else.
Two things were missing: a provider the presets do not know, and what the
model is asked to do while it answers. Both live under "Advanced options"
now, next to the entry they belong to.
Custom providers (engine)
- cst_models_user_set_provider() / cst_models_user_drop_provider() write the
engine's own user layer, <config>/cst-agent/models.json:
{"providers": {"myllm": {"api_base": ..., "key_env": ...,
"default_model": ..., "models": [...]}}}
and reload the registry, so the provider is at once in the provider list,
in the wire models[] and in the CLI's /provider -- the file a hand-written
override would go into is the same one, so the GUI and an editor agree.
The name is checked (1-31 of letters, digits, '.', '_', '-'), a preset's
name overrides that preset (a self-hosted endpoint, a proxy), everything
else in the file (model rules) is kept, and a file that is not a JSON
object is never overwritten. Dropping only removes the user's own entry.
- AgentCore::providers() gained "custom" (source == "user"), and
addProvider() / removeProvider() drive the two calls above.
Model parameters (engine + wire)
- cst_agent_config_t and cst_llm_request_t carry temperature, top_p and
max_tokens. 0 means the field is not sent at all -- an absent field is the
provider's own default, which "temperature": 0 is not (deterministic
decoding is a real choice), so cst_llm_build_body() only adds what was set.
tests/test_llm_body.c:test_gen_params pins both halves.
- settings.json keeps them per provider ("params": {...}) via
cst_settings_remember_params(); cst_settings_remember() carries them along
with the provider choice, and cst_agent_create() reads them, so a CLI or
TUI run sends what the window was set to. An all-zero config (the CLI)
leaves what the GUI saved alone.
- AgentCore::genParams() / setGenParams(), and useProvider() applies both the
remembered parameters of the provider it lands on and a base URL that was
edited in the dialog.
The dialog
- Rewritten around one scrolling body with a fixed footer: a long model list
used to push the key field and the buttons out of the dialog. The model
chips are filtered (a filter field appears past twelve) and capped at 40
with "... +N more", which is what a router with hundreds of models needs.
- "Advanced options" folds out the provider entry (name, base URL, key
variable, default model, models; Save provider / Remove from models.json)
and the model parameters (temperature, top-p, max tokens; empty = not
sent). The fields follow the picked provider and are what gets saved, so a
hand-typed edit is never lost to a stale property.
Verified: --uitest 67/67 (15 new checks: parameters round-trip through the
engine, a provider added through the form and removed again, a bad name
refused, a preset not removable), --selftest, ctest cst-app-selftest, and
make check with the new body test.
Version 0.8.15.
mode = coding
This commit is contained in:
@@ -148,6 +148,43 @@ 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.
|
||||
|
||||
## Custom providers, model parameters
|
||||
|
||||
The dialog's **Advanced options** covers the two things the presets cannot:
|
||||
a provider of your own, and what the model should do.
|
||||
|
||||
- A custom provider is an entry the engine keeps in
|
||||
`<config>/cst-agent/models.json` (layer 1 of the model registry, see
|
||||
`include/cst_models.h`) — the same file a hand-written override would go
|
||||
into, so the GUI and a text editor are interchangeable:
|
||||
|
||||
```json
|
||||
{ "providers": { "myllm": { "api_base": "https://api.example.com/v1",
|
||||
"key_env": "MYLLM_API_KEY",
|
||||
"default_model": "m1",
|
||||
"models": ["m1", "m2"] } } }
|
||||
```
|
||||
|
||||
`AgentCore::addProvider()` / `removeProvider()` write it
|
||||
(`cst_models_user_set_provider()` / `cst_models_user_drop_provider()`) and
|
||||
reload the registry, so the provider is in the list, in `models[]` on the
|
||||
wire and in `/provider` of the CLI at once. A name a preset uses overrides
|
||||
that preset (a self-hosted endpoint, a proxy); the other content of the file
|
||||
(model rules) is kept, and a file that is not a JSON object is never
|
||||
overwritten. The key is still a key: it is verified against `GET /models` of
|
||||
the new base URL before it is saved, and stored per provider name as before.
|
||||
- The generation parameters — **temperature**, **top-p**, **max tokens** — are
|
||||
remembered per provider in `settings.json` (`"params": {...}`,
|
||||
`cst_settings_remember_params()`), and applied to the engine's config by
|
||||
`AgentCore::setGenParams()`, `AgentCore::useProvider()` and
|
||||
`cst_agent_create()` (so a CLI or TUI run sends what the window was set to).
|
||||
An **empty field is not sent at all** — that is not the same as sending 0,
|
||||
which is a real temperature; `cst_llm_build_body()` only adds a field that
|
||||
was set (`tests/test_llm_body.c:test_gen_params`).
|
||||
- A long model list (a router lists hundreds) is filtered and capped at 40
|
||||
chips with "… +N more", and the whole dialog body scrolls: the buttons stay
|
||||
put and nothing can spill out of the frame.
|
||||
|
||||
## Attachments
|
||||
|
||||
The next prompt can carry files, and they arrive three ways:
|
||||
|
||||
+7
-4
@@ -180,8 +180,11 @@ ApplicationWindow {
|
||||
settings.open()
|
||||
}
|
||||
|
||||
function applySettings(provider, model, key) {
|
||||
const err = agent.useProvider(provider, model, key)
|
||||
function applySettings(provider, model, key, temperature, topP, maxTokens) {
|
||||
/* The parameters are stored first: useProvider() then picks up what
|
||||
* was remembered for whichever provider it ends on. */
|
||||
const perr = agent.setGenParams(provider, temperature, topP, maxTokens)
|
||||
const err = perr.length ? perr : agent.useProvider(provider, model, key)
|
||||
settings.note = err.length ? err
|
||||
: "using " + agent.provider + " / " + agent.model
|
||||
settings.choices = agent.modelChoices(provider)
|
||||
@@ -470,8 +473,8 @@ ApplicationWindow {
|
||||
|
||||
SettingsDialog {
|
||||
id: settings
|
||||
onApplyRequested: function (provider, model, key) {
|
||||
win.applySettings(provider, model, key)
|
||||
onApplyRequested: function (provider, model, key, temperature, topP, maxTokens) {
|
||||
win.applySettings(provider, model, key, temperature, topP, maxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+556
-178
@@ -3,11 +3,18 @@ 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.
|
||||
// at all. Everything here goes through AgentCore, 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.
|
||||
//
|
||||
// Three things live under "Advanced options", because they are for the odd
|
||||
// provider or the odd model: the provider's own entry (name, base URL, key
|
||||
// variable, models -- the engine keeps those in <config>/models.json, which is
|
||||
// how a provider the presets do not know is added or an existing one is
|
||||
// pointed somewhere else) and the generation parameters (temperature, top-p,
|
||||
// max tokens), which are remembered per provider.
|
||||
Dialog {
|
||||
id: dlg
|
||||
objectName: "settingsDialog"
|
||||
@@ -21,15 +28,27 @@ Dialog {
|
||||
property var choices: [] // AgentCore::modelChoices(picked)
|
||||
property string picked: ""
|
||||
property string model: ""
|
||||
property string note: "" // what the last "Use this" answered
|
||||
property string note: "" // what the last action answered
|
||||
property string keyEnv: ""
|
||||
property string keyFrom: ""
|
||||
property bool hasKey: false
|
||||
property bool advanced: false
|
||||
property string modelFilter: "" // narrows a long model list
|
||||
/* The advanced form: the picked provider's entry, editable. */
|
||||
property string advName: ""
|
||||
property string advBase: ""
|
||||
property string advKeyEnv: ""
|
||||
property string advModel: ""
|
||||
property string advModels: ""
|
||||
property string tempText: ""
|
||||
property string topPText: ""
|
||||
property string maxTokText: ""
|
||||
|
||||
signal applyRequested(string provider, string model, string key)
|
||||
signal applyRequested(string provider, string model, string key,
|
||||
real temperature, real topP, int maxTokens)
|
||||
|
||||
width: Math.min(580, parent ? parent.width - 32 : 580)
|
||||
height: Math.min(560, parent ? parent.height - 60 : 560)
|
||||
width: Math.min(660, parent ? parent.width - 32 : 660)
|
||||
height: Math.min(640, parent ? parent.height - 40 : 640)
|
||||
|
||||
/* The Basic style paints a light panel; this window is dark, so the
|
||||
* dialog brings its own surface like every other one here. */
|
||||
@@ -40,6 +59,35 @@ Dialog {
|
||||
border.color: Theme.border
|
||||
}
|
||||
|
||||
// A long provider list (a router lists hundreds) is a wall of chips: the
|
||||
// filter narrows it and the rest is one line ("+N more").
|
||||
readonly property int chipCap: 40
|
||||
readonly property var chips: {
|
||||
const f = modelFilter.trim().toLowerCase()
|
||||
const out = []
|
||||
for (let i = 0; i < choices.length; i++) {
|
||||
const c = String(choices[i])
|
||||
if (!f.length || c.toLowerCase().indexOf(f) >= 0)
|
||||
out.push(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
readonly property int chipsHidden: Math.max(0, chips.length - chipCap)
|
||||
|
||||
/* "0.2" -> 0.2; anything empty or unreadable -> 0 (the field is not sent,
|
||||
* which is not the same as sending 0). */
|
||||
function num(text) {
|
||||
const v = parseFloat(String(text).replace(",", "."))
|
||||
return isFinite(v) && v > 0 ? v : 0
|
||||
}
|
||||
|
||||
function pickedEntry() {
|
||||
for (let i = 0; i < providers.length; i++)
|
||||
if (providers[i].name === dlg.picked)
|
||||
return providers[i]
|
||||
return null
|
||||
}
|
||||
|
||||
/* Fill it in: the presets, the provider the engine is on now, and the
|
||||
* model that provider last used. */
|
||||
function load(list, provider, currentModel) {
|
||||
@@ -51,24 +99,33 @@ Dialog {
|
||||
if (list[i].current === true)
|
||||
picked = list[i].name
|
||||
choices = picked.length ? agent.modelChoices(picked) : []
|
||||
modelFilter = ""
|
||||
syncPick()
|
||||
}
|
||||
|
||||
/* Where the picked provider's key would come from. From the entries --
|
||||
* a key itself never reaches the QML side. */
|
||||
/* Where the picked provider's key would come from and what it is set to.
|
||||
* 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
|
||||
const p = pickedEntry()
|
||||
if (p) {
|
||||
hasKey = p.hasKey === true
|
||||
keyFrom = p.keyFrom
|
||||
keyEnv = p.keyEnv
|
||||
return
|
||||
/* The advanced form describes the picked provider: edit it, or
|
||||
* type another name to add one. */
|
||||
advName = p.name
|
||||
advBase = p.base
|
||||
advKeyEnv = p.keyEnv
|
||||
advModel = p.model
|
||||
advModels = p.custom === true ? agent.modelChoices(p.name).join(", ") : ""
|
||||
}
|
||||
const g = picked.length ? agent.genParams(picked) : null
|
||||
tempText = g && g.temperature > 0 ? String(g.temperature) : ""
|
||||
topPText = g && g.top_p > 0 ? String(g.top_p) : ""
|
||||
maxTokText = g && g.max_tokens > 0 ? String(g.max_tokens) : ""
|
||||
}
|
||||
|
||||
function keyState() {
|
||||
@@ -90,6 +147,87 @@ Dialog {
|
||||
return s
|
||||
}
|
||||
|
||||
/* Write the form's entry into the engine's user file and list it again. */
|
||||
function saveProvider() {
|
||||
/* The fields are the truth here, not the properties: a hand-typed
|
||||
* edit is what the user wants saved. */
|
||||
const name = advNameField.text.trim()
|
||||
const err = agent.addProvider(name, advBaseField.text.trim(),
|
||||
advKeyEnvField.text.trim(),
|
||||
advModelField.text.trim(),
|
||||
advModelsField.text)
|
||||
if (err.length) {
|
||||
note = err
|
||||
return
|
||||
}
|
||||
providers = agent.providers()
|
||||
picked = name
|
||||
choices = agent.modelChoices(name)
|
||||
syncPick()
|
||||
note = "saved " + name + " to models.json — it is in the list above now"
|
||||
}
|
||||
|
||||
function removeProvider() {
|
||||
const typed = advNameField.text.trim()
|
||||
const name = typed.length ? typed : picked
|
||||
const rc = agent.removeProvider(name)
|
||||
if (rc < 0) {
|
||||
note = "could not remove " + name
|
||||
return
|
||||
}
|
||||
providers = agent.providers()
|
||||
note = rc === 0
|
||||
? name + " is not in models.json (a preset of that name stays)"
|
||||
: "removed " + name + " from models.json"
|
||||
if (rc === 1 && providers.length > 0)
|
||||
picked = providers[0].name
|
||||
choices = picked.length ? agent.modelChoices(picked) : []
|
||||
syncPick()
|
||||
}
|
||||
|
||||
/* The form's fields follow the picked provider (and a user edit breaks
|
||||
* the binding, so the push is imperative). */
|
||||
onAdvNameChanged: if (advNameField && advNameField.text !== advName) advNameField.text = advName
|
||||
onAdvBaseChanged: if (advBaseField && advBaseField.text !== advBase) advBaseField.text = advBase
|
||||
onAdvKeyEnvChanged: if (advKeyEnvField && advKeyEnvField.text !== advKeyEnv) advKeyEnvField.text = advKeyEnv
|
||||
onAdvModelChanged: if (advModelField && advModelField.text !== advModel) advModelField.text = advModel
|
||||
onAdvModelsChanged: if (advModelsField && advModelsField.text !== advModels) advModelsField.text = advModels
|
||||
onTempTextChanged: if (tempField && tempField.text !== tempText) tempField.text = tempText
|
||||
onTopPTextChanged: if (topPField && topPField.text !== topPText) topPField.text = topPText
|
||||
onMaxTokTextChanged: if (maxTokField && maxTokField.text !== maxTokText) maxTokField.text = maxTokText
|
||||
|
||||
// One labelled field of the advanced form.
|
||||
component AdvField: ColumnLayout {
|
||||
property string label: ""
|
||||
property alias text: field.text
|
||||
property alias placeholder: field.placeholderText
|
||||
property int hintWidth: 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Text {
|
||||
text: parent.label
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
TextField {
|
||||
id: field
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.touchTarget
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: field.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
objectName: "settingsPanel"
|
||||
spacing: Theme.gap
|
||||
@@ -109,201 +247,427 @@ Dialog {
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
/* Everything scrolls, so the buttons stay where they are and a long
|
||||
* model list cannot push them out of the dialog. */
|
||||
Flickable {
|
||||
id: body
|
||||
objectName: "settingsBody"
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 96
|
||||
Layout.minimumHeight: 120
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: plist.implicitHeight
|
||||
contentHeight: bodyCol.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
ColumnLayout {
|
||||
id: plist
|
||||
id: bodyCol
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
spacing: Theme.gap
|
||||
|
||||
Repeater {
|
||||
model: dlg.providers
|
||||
delegate: Rectangle {
|
||||
id: prow
|
||||
required property var modelData
|
||||
readonly property bool selected: dlg.picked === modelData.name
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
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"
|
||||
Repeater {
|
||||
model: dlg.providers
|
||||
delegate: Rectangle {
|
||||
id: prow
|
||||
required property var modelData
|
||||
readonly property bool selected: dlg.picked === modelData.name
|
||||
|
||||
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()
|
||||
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.modelFilter = ""
|
||||
dlg.syncPick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: prowCol
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 4
|
||||
}
|
||||
spacing: 0
|
||||
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.accentText : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: prow.selected
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
text: prow.modelData.name
|
||||
color: prow.selected ? Theme.accentText : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: prow.selected
|
||||
}
|
||||
Text {
|
||||
visible: prow.modelData.current === true
|
||||
text: "in use"
|
||||
color: prow.selected ? Theme.okOnAccent : Theme.ok
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
text: prow.modelData.custom === true
|
||||
? "custom" : prow.modelData.source
|
||||
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: prow.modelData.current === true
|
||||
text: "in use"
|
||||
color: prow.selected ? Theme.okOnAccent : Theme.ok
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
text: prow.modelData.source
|
||||
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: prow.selected ? Theme.accentTextDim : 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: prow.selected ? Theme.accentTextDim : 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
|
||||
color: Theme.text
|
||||
placeholderText: "model name"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: modelField.activeFocus ? Theme.accentDim
|
||||
: Theme.border
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Model"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
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 }
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
TextField {
|
||||
id: modelField
|
||||
objectName: "settingsModel"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.model
|
||||
implicitHeight: Theme.touchTarget
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "model name"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: modelField.activeFocus ? Theme.accentDim
|
||||
: Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
id: chipText
|
||||
anchors.centerIn: parent
|
||||
text: chip.name
|
||||
color: modelField.text === chip.name ? Theme.text
|
||||
: Theme.textDim
|
||||
Layout.preferredWidth: 90
|
||||
elide: Text.ElideRight
|
||||
text: modelField.text.length ? agent.modelInfo(modelField.text) : ""
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
TapHandler { onTapped: modelField.text = chip.name }
|
||||
}
|
||||
|
||||
/* A router can list hundreds of models: filter, cap, say how
|
||||
* many are left instead of filling the dialog. */
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: dlg.choices.length > 12
|
||||
spacing: Theme.gap
|
||||
TextField {
|
||||
id: filterField
|
||||
objectName: "settingsFilter"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.modelFilter
|
||||
implicitHeight: Theme.segmentHeight
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "filter " + dlg.choices.length + " models"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
onTextChanged: dlg.modelFilter = text
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: filterField.activeFocus ? Theme.accentDim
|
||||
: Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.preferredWidth: 110
|
||||
elide: Text.ElideRight
|
||||
text: dlg.chips.length + " shown"
|
||||
+ (dlg.chipsHidden ? " · +" + dlg.chipsHidden + " more" : "")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
visible: dlg.choices.length > 0
|
||||
|
||||
Repeater {
|
||||
model: dlg.chips.slice(0, dlg.chipCap)
|
||||
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
|
||||
color: Theme.text
|
||||
placeholderText: "API key (leave empty to keep what is there)"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
|
||||
onAccepted: dlg.apply()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: keyField.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: dlg.keyState()
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
/* --- advanced ------------------------------------------- */
|
||||
ToolButton {
|
||||
id: advToggle
|
||||
objectName: "settingsAdvanced"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.segmentHeight
|
||||
onClicked: dlg.advanced = !dlg.advanced
|
||||
contentItem: RowLayout {
|
||||
spacing: 6
|
||||
ThemeIcon {
|
||||
name: dlg.advanced ? "arrow-down" : "arrow-right"
|
||||
glyph: dlg.advanced ? "▾" : "▸"
|
||||
size: 14
|
||||
color: Theme.textDim
|
||||
}
|
||||
Text {
|
||||
text: "Advanced options"
|
||||
color: advToggle.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
text: "custom provider · model parameters"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: advToggle.hovered ? Theme.surfaceAlt : "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: dlg.advanced
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Provider entry — saved in <config>/cst-agent/models.json. " +
|
||||
"A name the presets know is overridden, a new one is added."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 2
|
||||
columnSpacing: Theme.gap
|
||||
rowSpacing: 4
|
||||
|
||||
AdvField {
|
||||
id: advNameField
|
||||
objectName: "advName"
|
||||
label: "Name"
|
||||
placeholder: "myllm"
|
||||
Layout.preferredWidth: 180
|
||||
}
|
||||
AdvField {
|
||||
id: advBaseField
|
||||
objectName: "advBase"
|
||||
label: "Base URL"
|
||||
placeholder: "https://api.example.com/v1"
|
||||
}
|
||||
AdvField {
|
||||
id: advKeyEnvField
|
||||
objectName: "advKeyEnv"
|
||||
label: "Key variable"
|
||||
placeholder: "MYLLM_API_KEY"
|
||||
}
|
||||
AdvField {
|
||||
id: advModelField
|
||||
objectName: "advModel"
|
||||
label: "Default model"
|
||||
placeholder: "the model to start with"
|
||||
}
|
||||
AdvField {
|
||||
id: advModelsField
|
||||
objectName: "advModels"
|
||||
label: "Models it offers (comma separated, optional)"
|
||||
placeholder: "m1, m2"
|
||||
Layout.columnSpan: 2
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Button {
|
||||
id: saveProviderBtn
|
||||
objectName: "settingsSaveProvider"
|
||||
text: "Save provider"
|
||||
implicitHeight: Theme.touchTarget
|
||||
enabled: advNameField.text.trim().length > 0 &&
|
||||
advBaseField.text.trim().length > 0
|
||||
onClicked: dlg.saveProvider()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: saveProviderBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: saveProviderBtn.text
|
||||
color: saveProviderBtn.enabled ? Theme.textDim : Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Button {
|
||||
id: removeProviderBtn
|
||||
objectName: "settingsRemoveProvider"
|
||||
text: "Remove from models.json"
|
||||
implicitHeight: Theme.touchTarget
|
||||
readonly property var entry: dlg.pickedEntry()
|
||||
enabled: entry !== null && entry.custom === true
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Only the user's own entry goes: a built-in preset of the same name stays"
|
||||
onClicked: dlg.removeProvider()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: removeProviderBtn.hovered && removeProviderBtn.enabled
|
||||
? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: removeProviderBtn.text
|
||||
color: removeProviderBtn.enabled ? Theme.textDim
|
||||
: Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
text: "Model parameters — remembered for " +
|
||||
(dlg.picked.length ? dlg.picked : "this provider") +
|
||||
", sent with every request. Empty = the provider's own default."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
AdvField {
|
||||
id: tempField
|
||||
objectName: "advTemperature"
|
||||
label: "Temperature"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 130
|
||||
}
|
||||
AdvField {
|
||||
id: topPField
|
||||
objectName: "advTopP"
|
||||
label: "Top-p"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 130
|
||||
}
|
||||
AdvField {
|
||||
id: maxTokField
|
||||
objectName: "advMaxTokens"
|
||||
label: "Max tokens"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 150
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: keyField
|
||||
objectName: "settingsKey"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.touchTarget
|
||||
echoMode: TextInput.Password
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "API key (leave empty to keep what is there)"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
|
||||
onAccepted: dlg.applyRequested(dlg.picked, modelField.text, keyField.text)
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: keyField.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: dlg.keyState()
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
@@ -342,7 +706,7 @@ Dialog {
|
||||
objectName: "settingsApply"
|
||||
text: "Use this"
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: dlg.applyRequested(dlg.picked, modelField.text, keyField.text)
|
||||
onClicked: dlg.apply()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: applyBtn.hovered ? "#5cc0f0" : Theme.accent
|
||||
@@ -358,4 +722,18 @@ Dialog {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The model parameters belong to the provider that is picked, so they are
|
||||
* applied first: useProvider() then takes over what was remembered for
|
||||
* whichever provider it ends on. */
|
||||
function apply() {
|
||||
dlg.applyRequested(dlg.picked, modelField.text, keyField.text,
|
||||
dlg.num(tempField.text), dlg.num(topPField.text),
|
||||
Math.round(dlg.num(maxTokField.text)))
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
keyField.text = ""
|
||||
modelField.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +570,7 @@ QVariantList AgentCore::providers() const {
|
||||
m["keyFrom"] = utf8(from);
|
||||
m["keyEnv"] = utf8(p->key_env);
|
||||
m["source"] = utf8(cst_provider_source(p));
|
||||
m["custom"] = strcmp(cst_provider_source(p), "user") == 0;
|
||||
m["current"] = (m_cfg.provider[0] && strcmp(p->name, m_cfg.provider) == 0);
|
||||
out.append(m);
|
||||
}
|
||||
@@ -663,10 +664,122 @@ QString AgentCore::useProvider(const QString &provider, const QString &model,
|
||||
snprintf(cfg->model, sizeof(cfg->model), "%s", mdl.constData());
|
||||
cst_settings_remember(cfg);
|
||||
}
|
||||
/* A base URL edited in the advanced form (a custom provider, or a preset we
|
||||
* override) takes effect without a trip through a different provider. */
|
||||
const cst_provider_t *entry = cst_provider_find(name.constData());
|
||||
if (entry && entry->api_base && strcmp(entry->api_base, cfg->api_base) != 0)
|
||||
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", entry->api_base);
|
||||
/* And the generation parameters remembered for whichever provider we ended
|
||||
* on (cst_agent_create() does the same for the one it starts with). */
|
||||
cst_settings_t st;
|
||||
if (cst_settings_load(&st) == 0) {
|
||||
const cst_gen_params_t *g = cst_settings_params(&st, cfg->provider);
|
||||
cfg->temperature = g ? g->temperature : 0;
|
||||
cfg->top_p = g ? g->top_p : 0;
|
||||
cfg->max_tokens = g ? g->max_tokens : 0;
|
||||
}
|
||||
syncConfig();
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString AgentCore::addProvider(const QString &name, const QString &base,
|
||||
const QString &keyEnv,
|
||||
const QString &defaultModel,
|
||||
const QString &modelsCsv) {
|
||||
const QByteArray n = name.trimmed().toUtf8();
|
||||
const QByteArray b = base.trimmed().toUtf8();
|
||||
const QByteArray ke = keyEnv.trimmed().toUtf8();
|
||||
const QByteArray dm = defaultModel.trimmed().toUtf8();
|
||||
/* "a, b, c" -> the model list the engine will offer for it. */
|
||||
QVector<QByteArray> list;
|
||||
for (const QString &part : modelsCsv.split(QLatin1Char(','), Qt::SkipEmptyParts)) {
|
||||
const QByteArray t = part.trimmed().toUtf8();
|
||||
if (!t.isEmpty() && list.size() < 32)
|
||||
list.append(t);
|
||||
}
|
||||
QVector<const char *> ptrs;
|
||||
for (const QByteArray &p : list)
|
||||
ptrs.append(p.constData()); /* list is complete: no reallocation after */
|
||||
char err[512] = "";
|
||||
if (cst_models_user_set_provider(n.constData(), b.constData(),
|
||||
ke.isEmpty() ? nullptr : ke.constData(),
|
||||
dm.isEmpty() ? nullptr : dm.constData(),
|
||||
ptrs.isEmpty() ? nullptr : ptrs.constData(),
|
||||
ptrs.size(), err, sizeof(err)) != 0)
|
||||
return utf8(err[0] ? err : "the provider was refused");
|
||||
/* Editing the live provider's own entry: its new base URL is live at once. */
|
||||
if (m_agent && m_cfg.provider[0] && strcmp(m_cfg.provider, n.constData()) == 0) {
|
||||
const cst_provider_t *p = cst_provider_find(n.constData());
|
||||
if (p && p->api_base && strcmp(p->api_base, m_cfg.api_base) != 0) {
|
||||
cst_agent_config_t *cfg = cst_agent_config_mut(m_agent);
|
||||
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
|
||||
syncConfig();
|
||||
}
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
int AgentCore::removeProvider(const QString &name) {
|
||||
const QByteArray n = name.trimmed().toUtf8();
|
||||
if (n.isEmpty())
|
||||
return -1;
|
||||
char err[512] = "";
|
||||
const int rc = cst_models_user_drop_provider(n.constData(), err, sizeof(err));
|
||||
if (rc < 0)
|
||||
emit statusChanged(QStringLiteral("%1: %2")
|
||||
.arg(name, utf8(err[0] ? err : "not removed")));
|
||||
return rc;
|
||||
}
|
||||
|
||||
QVariantMap AgentCore::genParams(const QString &provider) const {
|
||||
QVariantMap m;
|
||||
m["temperature"] = 0.0;
|
||||
m["top_p"] = 0.0;
|
||||
m["max_tokens"] = 0;
|
||||
const QByteArray name = provider.trimmed().toUtf8();
|
||||
cst_settings_t st;
|
||||
if (cst_settings_load(&st) == 0) {
|
||||
const cst_gen_params_t *g = cst_settings_params(&st, name.constData());
|
||||
if (g) {
|
||||
m["temperature"] = g->temperature;
|
||||
m["top_p"] = g->top_p;
|
||||
m["max_tokens"] = g->max_tokens;
|
||||
}
|
||||
}
|
||||
/* The live provider's config is what actually goes out: show that, not what
|
||||
* a file says about it. */
|
||||
if (name.size() && m_cfg.provider[0] &&
|
||||
strcmp(m_cfg.provider, name.constData()) == 0) {
|
||||
m["temperature"] = m_cfg.temperature;
|
||||
m["top_p"] = m_cfg.top_p;
|
||||
m["max_tokens"] = m_cfg.max_tokens;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
QString AgentCore::setGenParams(const QString &provider, double temperature,
|
||||
double topP, int maxTokens) {
|
||||
const QByteArray name = provider.trimmed().toUtf8();
|
||||
if (name.isEmpty())
|
||||
return QStringLiteral("pick a provider first");
|
||||
cst_gen_params_t g;
|
||||
memset(&g, 0, sizeof(g));
|
||||
snprintf(g.provider, sizeof(g.provider), "%s", name.constData());
|
||||
g.temperature = temperature > 0 ? temperature : 0;
|
||||
g.top_p = topP > 0 ? topP : 0;
|
||||
g.max_tokens = maxTokens > 0 ? maxTokens : 0;
|
||||
if (cst_settings_remember_params(name.constData(), &g) != 0)
|
||||
return QStringLiteral("the parameters were not saved");
|
||||
if (m_agent && strcmp(m_cfg.provider, name.constData()) == 0) {
|
||||
cst_agent_config_t *cfg = cst_agent_config_mut(m_agent);
|
||||
cfg->temperature = g.temperature;
|
||||
cfg->top_p = g.top_p;
|
||||
cfg->max_tokens = g.max_tokens;
|
||||
syncConfig();
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
QVariantMap AgentCore::usage() const {
|
||||
QVariantMap m;
|
||||
if (!m_agent)
|
||||
|
||||
@@ -75,8 +75,29 @@ class AgentCore : public QObject {
|
||||
|
||||
/* 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. */
|
||||
* current, custom (the user's own provider, kept in the engine's user
|
||||
* model file). Never a key: the engine keeps those out of the UI's reach. */
|
||||
Q_INVOKABLE QVariantList providers() const;
|
||||
/* A provider the engine's presets do not know: it goes into the user model
|
||||
* file (<config>/cst-agent/models.json), which is where the registry keeps
|
||||
* them, and the registry is reloaded, so the next providers() call lists
|
||||
* it. `modelsCsv` is a comma-separated model list (may be empty).
|
||||
* "" when it worked, else the reason. */
|
||||
Q_INVOKABLE QString addProvider(const QString &name, const QString &base,
|
||||
const QString &keyEnv,
|
||||
const QString &defaultModel,
|
||||
const QString &modelsCsv);
|
||||
/* Drop a provider that came from that file (1 = it was there, 0 = it was
|
||||
* not, -1 = with a reason). A preset of the same name stays. */
|
||||
Q_INVOKABLE int removeProvider(const QString &name);
|
||||
/* The generation parameters remembered for a provider (0 = not set, the
|
||||
* field is not sent at all). */
|
||||
Q_INVOKABLE QVariantMap genParams(const QString &provider) const;
|
||||
/* Remember temperature / top-p / max tokens (0 = unset) for a provider and
|
||||
* apply them to the engine when that provider is the live one.
|
||||
* "" when it worked, else the reason. */
|
||||
Q_INVOKABLE QString setGenParams(const QString &provider, double temperature,
|
||||
double topP, int maxTokens);
|
||||
/* 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;
|
||||
|
||||
@@ -338,6 +338,112 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
check(false, QStringLiteral("the dialog has a model field"));
|
||||
}
|
||||
|
||||
/* --- advanced: a custom provider, added and removed ------------------- */
|
||||
/* The run has a scratch config dir (main.cpp points XDG_CONFIG_HOME at it),
|
||||
* so the engine's user model file is this run's own. */
|
||||
check(core.genParams(QStringLiteral("deepseek")).value(QStringLiteral("temperature"))
|
||||
.toDouble() == 0.0,
|
||||
QStringLiteral("no generation parameters are saved to begin with"));
|
||||
check(core.setGenParams(QStringLiteral("deepseek"), 0.2, 0.9, 4096).isEmpty(),
|
||||
QStringLiteral("generation parameters are accepted"));
|
||||
{
|
||||
const QVariantMap g = core.genParams(QStringLiteral("deepseek"));
|
||||
check(g.value(QStringLiteral("temperature")).toDouble() > 0.19 &&
|
||||
g.value(QStringLiteral("max_tokens")).toInt() == 4096,
|
||||
QStringLiteral("they are remembered per provider (%1 / %2 / %3)")
|
||||
.arg(g.value(QStringLiteral("temperature")).toDouble())
|
||||
.arg(g.value(QStringLiteral("top_p")).toDouble())
|
||||
.arg(g.value(QStringLiteral("max_tokens")).toInt()));
|
||||
}
|
||||
check(core.addProvider(QStringLiteral("cst-scratch"), QStringLiteral("https://api.example.com/v1"),
|
||||
QStringLiteral("CST_SCRATCH_KEY"), QStringLiteral("scratch-1"),
|
||||
QStringLiteral("scratch-1, scratch-2"))
|
||||
.isEmpty(),
|
||||
QStringLiteral("a provider the presets do not know is added"));
|
||||
{
|
||||
bool found = false, custom = false;
|
||||
QString base, models;
|
||||
const QVariantList list = core.providers();
|
||||
for (const QVariant &v : list) {
|
||||
const QVariantMap p = v.toMap();
|
||||
if (p.value(QStringLiteral("name")).toString() != QLatin1String("cst-scratch"))
|
||||
continue;
|
||||
found = true;
|
||||
custom = p.value(QStringLiteral("custom")).toBool();
|
||||
base = p.value(QStringLiteral("base")).toString();
|
||||
models = core.modelChoices(QStringLiteral("cst-scratch")).join(QLatin1Char(','));
|
||||
}
|
||||
check(found && custom && base.contains(QLatin1String("api.example.com")),
|
||||
QStringLiteral("it shows up in the provider list as a custom one"));
|
||||
check(models.contains(QLatin1String("scratch-1")) &&
|
||||
models.contains(QLatin1String("scratch-2")),
|
||||
QStringLiteral("the models it lists are the picker's (%1)").arg(models));
|
||||
}
|
||||
check(!core.addProvider(QStringLiteral("bad name"), QStringLiteral("https://x/v1"),
|
||||
QString(), QString(), QString())
|
||||
.isEmpty(),
|
||||
QStringLiteral("a name that is not a provider name is refused"));
|
||||
check(core.removeProvider(QStringLiteral("cst-scratch")) == 1,
|
||||
QStringLiteral("the custom provider can be removed again"));
|
||||
{
|
||||
bool gone = true;
|
||||
const QVariantList list = core.providers();
|
||||
for (const QVariant &v : list)
|
||||
if (v.toMap().value(QStringLiteral("name")).toString() ==
|
||||
QLatin1String("cst-scratch"))
|
||||
gone = false;
|
||||
check(gone, QStringLiteral("and it is gone from the list"));
|
||||
}
|
||||
check(core.removeProvider(QStringLiteral("deepseek")) == 0,
|
||||
QStringLiteral("a preset cannot be removed (there is no user entry to drop)"));
|
||||
check(core.setGenParams(QStringLiteral("deepseek"), 0, 0, 0).isEmpty() &&
|
||||
core.genParams(QStringLiteral("deepseek"))
|
||||
.value(QStringLiteral("temperature"))
|
||||
.toDouble() == 0.0,
|
||||
QStringLiteral("parameters can be cleared again"));
|
||||
|
||||
/* The dialog drives the same two engine calls: open it, fold the advanced
|
||||
* section out and put a provider in through the form. */
|
||||
clickMouse(win, QStringLiteral("settingsButton"));
|
||||
if (panel && mfield) {
|
||||
clickMouse(win, QStringLiteral("settingsAdvanced"));
|
||||
QQuickItem *nfield = item(win, QStringLiteral("advName"));
|
||||
QQuickItem *bfield = item(win, QStringLiteral("advBase"));
|
||||
check(nfield && nfield->isVisible(), QStringLiteral("Advanced options opens in the dialog"));
|
||||
maybeShot(win, QStringLiteral("-advanced"));
|
||||
if (nfield && bfield) {
|
||||
nfield->setProperty("text", QStringLiteral("cst-form"));
|
||||
bfield->setProperty("text", QStringLiteral("https://form.example.com/v1"));
|
||||
/* The form sits at the end of the dialog's scrolling body: scroll it
|
||||
* into view so the click lands on the button, not on the frame. */
|
||||
QQuickItem *scroller = item(win, QStringLiteral("settingsBody"));
|
||||
if (scroller) {
|
||||
scroller->setProperty("contentY", scroller->property("contentHeight").toReal() -
|
||||
scroller->height());
|
||||
QTest::qWait(120); /* the flickable settles */
|
||||
}
|
||||
clickMouse(win, QStringLiteral("settingsSaveProvider"));
|
||||
bool found = false;
|
||||
const QVariantList list = core.providers();
|
||||
for (const QVariant &v : list)
|
||||
if (v.toMap().value(QStringLiteral("name")).toString() ==
|
||||
QLatin1String("cst-form"))
|
||||
found = true;
|
||||
check(found, QStringLiteral("the form in the dialog adds a provider"));
|
||||
QQuickItem *rm = item(win, QStringLiteral("settingsRemoveProvider"));
|
||||
check(rm && rm->property("enabled").toBool(),
|
||||
QStringLiteral("its entry can be removed from the form"));
|
||||
clickMouse(win, QStringLiteral("settingsRemoveProvider"));
|
||||
check(core.removeProvider(QStringLiteral("cst-form")) == 0,
|
||||
QStringLiteral("the form's provider is gone again"));
|
||||
} else {
|
||||
check(false, QStringLiteral("the advanced form has name and base URL fields"));
|
||||
}
|
||||
clickMouse(win, QStringLiteral("settingsClose"));
|
||||
} else {
|
||||
check(false, QStringLiteral("the settings dialog can be reopened"));
|
||||
}
|
||||
|
||||
/* --- attachments: a file goes next to the session -------------------- */
|
||||
const QString att = core.workspace() + QStringLiteral("/note.txt");
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user