diff options
| -rw-r--r-- | desktop/modules/assistant/AssistantModule.qml | 70 | ||||
| -rw-r--r-- | desktop/modules/assistant/AssistantPage.qml | 22 | ||||
| -rw-r--r-- | desktop/modules/assistant/README.md | 4 |
3 files changed, 82 insertions, 14 deletions
diff --git a/desktop/modules/assistant/AssistantModule.qml b/desktop/modules/assistant/AssistantModule.qml index 5e7673c..a05556d 100644 --- a/desktop/modules/assistant/AssistantModule.qml +++ b/desktop/modules/assistant/AssistantModule.qml @@ -67,17 +67,24 @@ Module { // 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. + // 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 && 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}] @@ -183,12 +190,14 @@ Module { } am.error = ""; const t = am.draft; - const changed = t !== am.text || Object.keys(am.toolsWanted).length > 0; - const tools = () => am.applyTools(Object.keys(am.toolsWanted), () => { - if (changed) am.pending = true; - am.listTools(); - if (done) done(); - }); + 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; } @@ -201,6 +210,7 @@ Module { am.error = ""; am.invalid = ({}); am.toolsWanted = ({}); + am.cloudWanted = ({}); am.base = am.text; am.draft = am.text; } @@ -246,9 +256,10 @@ Module { + "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() { @@ -264,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) { diff --git a/desktop/modules/assistant/AssistantPage.qml b/desktop/modules/assistant/AssistantPage.qml index 5878b3b..bf35763 100644 --- a/desktop/modules/assistant/AssistantPage.qml +++ b/desktop/modules/assistant/AssistantPage.qml @@ -233,7 +233,7 @@ Column { // The saved cloud models: Prefer only, there is nothing to load. Repeater { - model: page.asst.savedClouds.flatMap(c => c.models.map(m => c.name + ":" + m)) + model: page.asst.cloudIds delegate: Rectangle { id: cloudRow @@ -640,7 +640,8 @@ Column { Small { width: page.width wrapMode: Text.Wrap - text: "What the model may call. Off renames the file to .toml.off, which the daemon skips." + text: "What the model may call. Off renames the file to .toml.off, which the daemon skips. " + + "Cloud also offers it to cloud chats, which then receive its results." } Repeater { @@ -659,9 +660,24 @@ Column { checked: page.asst.toolOn(toolRow.modelData) onToggled: page.asst.toggleTool(toolRow.modelData) } + // nf-fa-cloud, then the tool's cloud = true. + Switch { + id: cloudSw + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + checked: page.asst.toolCloud(toolRow.modelData) + onToggled: page.asst.toggleCloud(toolRow.modelData) + } + Text { + id: cloudMark + anchors { right: cloudSw.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + text: "\uf0c2" + color: page.asst.toolCloud(toolRow.modelData) ? Theme.text : Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } Column { id: toolText - anchors { left: toolSw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } + anchors { left: toolSw.right; leftMargin: 10; right: cloudMark.left; rightMargin: 10 + verticalCenter: parent.verticalCenter } Text { text: toolRow.modelData.name color: page.asst.toolOn(toolRow.modelData) ? Theme.text : Theme.subtext diff --git a/desktop/modules/assistant/README.md b/desktop/modules/assistant/README.md index 9ee9736..a1ce840 100644 --- a/desktop/modules/assistant/README.md +++ b/desktop/modules/assistant/README.md @@ -13,7 +13,9 @@ are never copied here. A refused number is outlined and holds Save back. The system prompt also has "Use the default". The tool environment (`[env]`) is one row per key, with an add row below. A tool's switch, once saved, renames `tools/<name>.toml` -to `<name>.toml.off`, which the daemon's `*.toml` glob skips. +to `<name>.toml.off`, which the daemon's `*.toml` glob skips. Its cloud switch sets or drops +`cloud = true` at the top of the file, which offers the tool to cloud +chats; the file is checked with `tomllib` and replaced atomically. **Cloud** edits the daemon's `[[cloud]]` tables: name, URL and key command per provider. Fetch saves the page, then runs `assistant.py |
