aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/assistant/AssistantModule.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/assistant/AssistantModule.qml')
-rw-r--r--desktop/modules/assistant/AssistantModule.qml125
1 files changed, 109 insertions, 16 deletions
diff --git a/desktop/modules/assistant/AssistantModule.qml b/desktop/modules/assistant/AssistantModule.qml
index 1e3c1e0..a05556d 100644
--- a/desktop/modules/assistant/AssistantModule.qml
+++ b/desktop/modules/assistant/AssistantModule.qml
@@ -27,7 +27,7 @@ Module {
settings: true
group: "Assistant"
keywords: ["llm", "model", "llama", "server", "prompt", "temperature", "thinking",
- "rounds", "tools", "environment", "daemon", "restart"]
+ "rounds", "tools", "environment", "daemon", "restart", "cloud", "provider"]
readonly property string home: Quickshell.env("HOME")
readonly property string daemonDir: home + "/Programming/GIT/desktop-assistant"
@@ -57,14 +57,34 @@ Module {
onDraftChanged: {
const k = C.env(am.draft).map(e => e.key);
if (JSON.stringify(k) !== JSON.stringify(am.envKeys)) am.envKeys = k;
+ const n = (C.clouds(am.draft) ?? []).length;
+ if (n !== am.cloudCount) am.cloudCount = n;
}
function envValue(key) { return (am.envList.find(e => e.key === key) ?? { value: "" }).value; }
- // Tool switches flipped but not saved: name -> wanted on.
+ // The [[cloud]] tables in the draft, null when one cannot be read (the
+ // page then leaves them alone), and the saved ones, which the canvas and
+ // Prefer offer. cloudCount follows the draft's count only, so rows are
+ // not rebuilt on each keystroke (as envKeys).
+ readonly property var clouds: C.clouds(am.draft)
+ readonly property var savedClouds: C.clouds(am.text) ?? []
+ // Their models as the canvas names them, "provider:model". reduce, not
+ // flatMap: the shell's JS engine has no flatMap.
+ readonly property var cloudIds: am.savedClouds.reduce(
+ (a, c) => a.concat(c.models.map(m => c.name + ":" + m)), [])
+ property int cloudCount: 0
+ readonly property string cloudError: am.clouds ? C.cloudError(am.clouds) : ""
+ // name -> the provider's ids, or why they could not be fetched.
+ property var fetched: ({})
+ property string fetching: ""
+ // Tool switches flipped but not saved: name -> wanted on; and the same
+ // for each tool's cloud = true, which offers it to cloud chats.
property var toolsWanted: ({})
+ property var cloudWanted: ({})
// Fields holding a value that cannot be written; Save waits for none.
property var invalid: ({})
- readonly property bool valid: Object.keys(am.invalid).length === 0
+ readonly property bool valid: Object.keys(am.invalid).length === 0 && am.cloudError === ""
dirty: am.draft !== am.base || Object.keys(am.toolsWanted).length > 0
+ || Object.keys(am.cloudWanted).length > 0
// The daemon's built-in values, from assistant.py --defaults.
property var defaults: ({})
// [{name, on, description}]
@@ -163,14 +183,21 @@ Module {
// The config first, then the tool renames, each only when there is
// something to do; any failure stops there and says why.
- function save() {
+ function save(done) {
if (am.text !== am.base) {
am.error = "config.toml changed on disk since these edits began. Discard to load it, then redo them.";
return;
}
am.error = "";
const t = am.draft;
- const tools = () => am.applyTools(Object.keys(am.toolsWanted), () => { am.pending = true; am.listTools(); });
+ const changed = t !== am.text || Object.keys(am.toolsWanted).length > 0
+ || Object.keys(am.cloudWanted).length > 0;
+ const tools = () => am.applyTools(Object.keys(am.toolsWanted), () =>
+ am.applyCloud(Object.keys(am.cloudWanted), () => {
+ if (changed) am.pending = true;
+ am.listTools();
+ if (done) done();
+ }));
if (t === am.text) { tools(); return; }
am.run([am.python, "-c", am.writer, am.cfgPath], t, (code, out, err) => {
if (code !== 0) { am.error = err.trim() || "config.toml could not be written"; return; }
@@ -183,6 +210,7 @@ Module {
am.error = "";
am.invalid = ({});
am.toolsWanted = ({});
+ am.cloudWanted = ({});
am.base = am.text;
am.draft = am.text;
}
@@ -197,15 +225,41 @@ Module {
function setEnv(key, value) { am.draft = C.setEnv(am.draft, key, value); }
function quote(s) { return C.quote(s); }
+ // patch is {name}, {url}, {key_cmd} or {models}; i === cloudCount adds one.
+ function editCloud(i, patch) {
+ const list = (am.clouds ?? []).map(c => Object.assign({}, c));
+ if (i === list.length) list.push({ name: "", url: "", key_cmd: "", models: [] });
+ Object.assign(list[i], patch);
+ am.draft = C.setClouds(am.draft, list);
+ }
+ function removeCloud(i) { am.draft = C.setClouds(am.draft, am.clouds.filter((c, j) => j !== i)); }
+
+ // The daemon reads the saved config, so Fetch saves the draft first.
+ // The key command runs in that process; the key never reaches here.
+ function fetchModels(name) {
+ am.save(() => {
+ am.fetching = name;
+ am.run([am.python, am.daemonDir + "/assistant.py", "--cloud-models", name], null,
+ (code, out, err) => {
+ am.fetching = "";
+ let got;
+ try { got = code === 0 ? JSON.parse(out) : (err.trim().split("\n").pop() || `exit ${code}`); }
+ catch (e) { got = "the assistant sent an unreadable list"; }
+ am.fetched = Object.assign({}, am.fetched, { [name]: got });
+ });
+ });
+ }
+
// Every tools/*.toml is loaded; *.toml.off is the same file switched off.
readonly property string lister: "import glob, json, os, sys, tomllib\n"
+ "out = []\n"
+ "for p in glob.glob(sys.argv[1] + '/*.toml') + glob.glob(sys.argv[1] + '/*.toml.off'):\n"
+ " on = p.endswith('.toml')\n"
+ " name = os.path.basename(p).removesuffix('.off').removesuffix('.toml')\n"
- + " try:\n d = tomllib.load(open(p, 'rb')).get('description', '')\n"
- + " except Exception as e:\n d = f'unreadable: {e}'\n"
- + " out.append({'name': name, 'on': on, 'description': d})\n"
+ + " try:\n t = tomllib.load(open(p, 'rb'))\n"
+ + " d, cloud = t.get('description', ''), t.get('cloud') is True\n"
+ + " except Exception as e:\n d, cloud = f'unreadable: {e}', False\n"
+ + " out.append({'name': name, 'on': on, 'cloud': cloud, 'description': d})\n"
+ "print(json.dumps(sorted(out, key=lambda t: t['name'])))\n"
function listTools() {
@@ -221,6 +275,45 @@ Module {
if (!am.toolOn(t) === t.on) delete next[t.name]; else next[t.name] = !am.toolOn(t);
am.toolsWanted = next;
}
+ // Whether a tool goes to cloud chats in the draft, as toolOn.
+ function toolCloud(t) { return am.cloudWanted[t.name] ?? t.cloud; }
+ function toggleCloud(t) {
+ const next = Object.assign({}, am.cloudWanted);
+ if (!am.toolCloud(t) === t.cloud) delete next[t.name]; else next[t.name] = !am.toolCloud(t);
+ am.cloudWanted = next;
+ }
+
+ // Sets or drops a tool file's top-level cloud line, validated with
+ // tomllib and replaced atomically, like config.toml. The file is
+ // name.toml or, switched off, name.toml.off. Added as the first line:
+ // a top-level key is valid there whatever the file holds below.
+ readonly property string clouder: "import os, re, sys, tomllib\n"
+ + "p, on = sys.argv[1], sys.argv[2] == '1'\n"
+ + "if not os.path.exists(p):\n p += '.off'\n"
+ + "lines = open(p).read().split('\\n')\n"
+ + "head = next((i for i, l in enumerate(lines) if l.lstrip().startswith('[')), len(lines))\n"
+ + "lines = [l for i, l in enumerate(lines) if i >= head or not re.match(r'\\s*cloud\\s*=', l)]\n"
+ + "if on:\n lines.insert(0, 'cloud = true')\n"
+ + "t = '\\n'.join(lines)\n"
+ + "try:\n tomllib.loads(t)\n"
+ + "except tomllib.TOMLDecodeError as e:\n sys.exit(f'{os.path.basename(p)}: not valid TOML: {e}')\n"
+ + "with open(p + '.tmp', 'w') as f:\n f.write(t)\n"
+ + "os.replace(p + '.tmp', p)\n"
+
+ // One file at a time, after the renames, then done().
+ function applyCloud(names, done) {
+ if (names.length === 0) { done(); return; }
+ const name = names[0];
+ am.run([am.python, "-c", am.clouder, am.toolsDir + "/" + name + ".toml",
+ am.cloudWanted[name] ? "1" : "0"], null, (code, out, err) => {
+ if (code !== 0) { am.error = err.trim() || "could not change " + name; am.listTools(); return; }
+ const next = Object.assign({}, am.cloudWanted);
+ delete next[name];
+ am.cloudWanted = next;
+ am.applyCloud(names.slice(1), done);
+ });
+ }
+
// One rename at a time, then done(). -n never overwrites a file that
// already holds the other name.
function applyTools(names, done) {
@@ -269,17 +362,17 @@ Module {
else { poll.left = 300; poll.start(); }
});
}
- // Called when the assistant canvas opens: the preferred model, if one is
- // set, still listed, and neither loaded nor loading, is loaded. One model
- // and one client at a time is how this desktop runs, so opening the
- // assistant is the moment to bring its model back. The list is read on
- // every open, preferred model or not, so hears is current.
- function ensurePreferred() {
+ // Called when the canvas opens and when its chat's model changes, with
+ // the chat's model, else the preferred one: loaded if it is a router
+ // model neither loaded nor loading. A cloud id is not in the router's
+ // list, so nothing is swapped for it. One model and one client at a time
+ // is how this desktop runs, so this is the moment to bring the model
+ // back. The list is read every time, so hears is current.
+ function ensureModel(id) {
if (am.loading !== "") return;
am.listModels(() => {
- const id = Prefs.assistantModel;
const m = am.models.find(x => x.id === id);
- if (id !== "" && m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id);
+ if (m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id);
});
}