diff options
Diffstat (limited to 'desktop/modules/assistant/AssistantModule.qml')
| -rw-r--r-- | desktop/modules/assistant/AssistantModule.qml | 125 |
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); }); } |
