aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/assistant/AssistantPage.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/assistant/AssistantPage.qml')
-rw-r--r--desktop/modules/assistant/AssistantPage.qml420
1 files changed, 420 insertions, 0 deletions
diff --git a/desktop/modules/assistant/AssistantPage.qml b/desktop/modules/assistant/AssistantPage.qml
new file mode 100644
index 0000000..e5d76c8
--- /dev/null
+++ b/desktop/modules/assistant/AssistantPage.qml
@@ -0,0 +1,420 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import QtQuick.Controls
+import "../.."
+
+// The view over AssistantModule: the model settings, the tools' environment,
+// the tools themselves, and the restart that applies them. A field saves on
+// Enter; emptied, it falls back to the daemon's default, which it shows as
+// its placeholder.
+Column {
+ id: page
+ spacing: 8
+
+ // The module. Not named `am`, its id (it would bind to itself).
+ required property QtObject asst
+
+ readonly property var v: asst.values
+ readonly property var d: asst.defaults
+
+ // What the daemon would run with: the file's value, else the default.
+ function shown(key) {
+ const x = page.v[key] !== undefined ? page.v[key] : page.d[key];
+ return x === undefined || x === null ? "" : String(x);
+ }
+
+ component Small: Text {
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ component Heading: Text {
+ topPadding: 14
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
+ color: Theme.text
+ }
+
+ // A one-line input that saves on Enter and undoes on Escape; edited
+ // reports every keystroke, for the add row.
+ component Field: Rectangle {
+ id: field
+ property string value: ""
+ property string placeholder: ""
+ signal commit(string text)
+ signal edited(string text)
+ // Commit on Enter even when unchanged: the add row's fields track
+ // their value as they are typed, so they never differ from it.
+ property bool live: false
+ height: 34
+ radius: 8
+ color: Theme.surface
+ TextInput {
+ id: ti
+ anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
+ verticalAlignment: TextInput.AlignVCenter
+ clip: true
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ text: field.value
+ onTextEdited: field.edited(text)
+ onAccepted: if (field.live || text !== field.value) field.commit(text)
+ Keys.onEscapePressed: event => {
+ if (text !== field.value) text = field.value;
+ else event.accepted = false;
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width
+ elide: Text.ElideRight
+ visible: parent.text === ""
+ text: field.placeholder
+ color: Theme.overlay
+ font: parent.font
+ }
+ }
+ }
+
+ // A labelled row: label on the left, the control filling the rest.
+ component Labelled: Item {
+ property alias label: lbl.text
+ default property alias control: slot.data
+ width: page.width
+ height: 34
+ Text {
+ id: lbl
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 120
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Item {
+ id: slot
+ anchors { left: lbl.right; leftMargin: 8; right: parent.right; top: parent.top; bottom: parent.bottom }
+ }
+ }
+
+ // --- restart ---
+
+ Rectangle {
+ width: page.width
+ height: bar.implicitHeight + 20
+ radius: 10
+ color: page.asst.pending ? Qt.alpha(Theme.accent, 0.16) : Qt.alpha(Theme.surface, 0.35)
+ border.width: 1
+ border.color: page.asst.pending ? Qt.alpha(Theme.accent, 0.5) : Qt.alpha(Theme.text, 0.08)
+
+ Column {
+ id: bar
+ anchors { left: parent.left; right: parent.right; verticalCenter: parent.verticalCenter; margins: 12 }
+ spacing: 6
+
+ Item {
+ width: parent.width
+ height: 30
+ Text {
+ anchors { left: parent.left; right: restartBtn.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ wrapMode: Text.Wrap
+ text: page.asst.pending ? "Saved. The assistant reads these at startup: restart it to apply."
+ : "The assistant reads these at startup."
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Button {
+ id: restartBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: page.asst.restarting ? "Restarting…" : "Restart assistant"
+ enabled: !page.asst.restarting
+ onClicked: page.asst.restart()
+ }
+ }
+
+ Small {
+ width: parent.width
+ visible: page.asst.status !== ""
+ wrapMode: Text.Wrap
+ text: page.asst.status
+ color: page.asst.status === "Restarted." ? Theme.green : Theme.red
+ }
+ }
+ }
+
+ Text {
+ width: page.width
+ visible: page.asst.error !== ""
+ wrapMode: Text.Wrap
+ text: page.asst.error
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ // --- model ---
+
+ Heading { text: "Model" }
+
+ Labelled {
+ objectName: "key:llm"
+ label: "Server"
+ Field {
+ anchors.fill: parent
+ value: page.v.llm ?? ""
+ placeholder: page.d.llm ?? ""
+ onCommit: t => page.asst.setKey("llm", t.trim() ? page.asst.quote(t.trim()) : null)
+ }
+ }
+
+ Labelled {
+ objectName: "key:temperature"
+ label: "Temperature"
+ Field {
+ id: temp
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
+ width: 120
+ value: page.v.temperature !== undefined ? String(page.v.temperature) : ""
+ placeholder: "the model's preset"
+ onCommit: t => {
+ const x = Number(t.trim());
+ if (t.trim() === "") page.asst.setKey("temperature", null);
+ else if (!isNaN(x) && x >= 0 && x <= 2) page.asst.setKey("temperature", String(x));
+ else page.asst.error = "Temperature is a number from 0 to 2.";
+ }
+ }
+ Small {
+ anchors { left: temp.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: "0 to 2; empty uses the llama-server preset's"
+ }
+ }
+
+ Labelled {
+ objectName: "key:tool_rounds"
+ label: "Tool rounds"
+ Field {
+ id: rounds
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
+ width: 120
+ value: page.v.tool_rounds !== undefined ? String(page.v.tool_rounds) : ""
+ placeholder: page.d.tool_rounds !== undefined ? String(page.d.tool_rounds) : ""
+ onCommit: t => {
+ const n = Number(t.trim());
+ if (t.trim() === "") page.asst.setKey("tool_rounds", null);
+ else if (Number.isInteger(n) && n >= 0 && n <= 20) page.asst.setKey("tool_rounds", String(n));
+ else page.asst.error = "Tool rounds is a whole number from 0 to 20.";
+ }
+ }
+ Small {
+ anchors { left: rounds.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: "tool round trips per question"
+ }
+ }
+
+ Labelled {
+ objectName: "key:thinking"
+ label: "Thinking"
+ Switch {
+ id: think
+ anchors.verticalCenter: parent.verticalCenter
+ checked: page.shown("thinking") !== "false"
+ onToggled: page.asst.setKey("thinking", checked ? "true" : "false")
+ }
+ Small {
+ anchors { left: think.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: "off skips the model's reasoning: a faster first word"
+ }
+ }
+
+ // The prompt is long and multi-line, so it gets its own box and an
+ // explicit Save rather than Enter.
+ Column {
+ objectName: "key:system"
+ width: page.width
+ spacing: 6
+
+ Text {
+ text: "System prompt"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 130
+ radius: 8
+ color: Theme.surface
+
+ ScrollView {
+ anchors { fill: parent; margins: 8 }
+ TextArea {
+ id: prompt
+ wrapMode: TextEdit.Wrap
+ selectByMouse: true
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ background: null
+ padding: 0
+ text: page.shown("system")
+ }
+ }
+ }
+
+ Row {
+ spacing: 8
+ Button {
+ text: "Save prompt"
+ enabled: prompt.text.trim() !== "" && prompt.text !== page.shown("system")
+ onClicked: page.asst.setKey("system", prompt.text === page.d.system ? null
+ : page.asst.quote(prompt.text))
+ }
+ Button {
+ text: "Use the default"
+ enabled: page.v.system !== undefined
+ onClicked: page.asst.setKey("system", null)
+ }
+ Small {
+ anchors.verticalCenter: parent.verticalCenter
+ text: page.v.system !== undefined ? "custom" : "default"
+ }
+ }
+ }
+
+ // --- env ---
+
+ Heading { text: "Tool environment" }
+ Small {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: "Added to every tool's environment: instance URLs, key file paths."
+ }
+
+ property string confirming: ""
+
+ Repeater {
+ model: page.asst.envList
+
+ delegate: Item {
+ id: envRow
+ required property var modelData
+ objectName: "env:" + modelData.key
+ width: page.width
+ height: 34
+
+ Text {
+ id: envKey
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 120
+ elide: Text.ElideRight
+ text: envRow.modelData.key
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Field {
+ anchors { left: envKey.right; leftMargin: 8; right: envDel.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
+ value: envRow.modelData.value
+ onCommit: t => page.asst.setEnv(envRow.modelData.key, t)
+ }
+ Button {
+ id: envDel
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ danger: true
+ text: page.confirming === envRow.modelData.key ? "Confirm" : "Remove"
+ onClicked: {
+ if (page.confirming === envRow.modelData.key) {
+ page.confirming = "";
+ page.asst.setEnv(envRow.modelData.key, null);
+ } else page.confirming = envRow.modelData.key;
+ }
+ }
+ }
+ }
+
+ // The add row: a key, a value, Add.
+ property string newKey: ""
+ property string newValue: ""
+ readonly property bool keyOk: /^[A-Za-z_][A-Za-z0-9_]*$/.test(page.newKey)
+ && !page.asst.envList.some(e => e.key === page.newKey)
+ function addEnv() {
+ if (!page.keyOk) return;
+ page.asst.setEnv(page.newKey, page.newValue);
+ page.newKey = "";
+ page.newValue = "";
+ }
+
+ Item {
+ width: page.width
+ height: 34
+ Field {
+ id: keyField
+ anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
+ width: 120
+ value: page.newKey
+ placeholder: "NEW_KEY"
+ onEdited: t => page.newKey = t
+ }
+ Field {
+ anchors { left: keyField.right; leftMargin: 8; right: addBtn.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
+ value: page.newValue
+ placeholder: "value"
+ live: true
+ onEdited: t => page.newValue = t
+ onCommit: page.addEnv()
+ }
+ Button {
+ id: addBtn
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: "Add"
+ enabled: page.keyOk
+ onClicked: page.addEnv()
+ }
+ }
+
+ // --- tools ---
+
+ Heading { text: "Tools" }
+ Small {
+ width: page.width
+ wrapMode: Text.Wrap
+ text: "What the model may call. Off renames the file to .toml.off, which the daemon skips."
+ }
+
+ Repeater {
+ model: page.asst.tools
+
+ delegate: Item {
+ id: toolRow
+ required property var modelData
+ objectName: "tool:" + modelData.name
+ width: page.width
+ height: Math.max(40, toolText.implicitHeight + 10)
+
+ Switch {
+ id: toolSw
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ checked: toolRow.modelData.on
+ onToggled: page.asst.toggleTool(toolRow.modelData)
+ }
+ Column {
+ id: toolText
+ anchors { left: toolSw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
+ Text {
+ text: toolRow.modelData.name
+ color: toolRow.modelData.on ? Theme.text : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ }
+ Small {
+ width: parent.width
+ wrapMode: Text.Wrap
+ text: toolRow.modelData.description
+ }
+ }
+ }
+ }
+}