aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/assistant
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/assistant')
-rw-r--r--desktop/assistant/Assistant.qml84
-rw-r--r--desktop/assistant/Card.qml85
-rw-r--r--desktop/assistant/Ops.js53
-rw-r--r--desktop/assistant/README.md12
-rw-r--r--desktop/assistant/test_ops.js45
5 files changed, 273 insertions, 6 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index 0139ef9..b1329b1 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -41,6 +41,22 @@ Scope {
// entry: one line. file: files staged, nothing asked yet. chat: turns.
readonly property string mode: turns.count || root.chatting ? "chat"
: root.staged.length ? "file" : "entry"
+ // The Do row's operation: one at a time, either fileops (a Process) or
+ // the daemon's write (To .md). Its result stays on the card.
+ property string opLabel: ""
+ property bool opRunning: false
+ property bool opDaemon: false
+ property int opChars: 0
+ property var opOut: []
+ property string opError: ""
+ // Results folder: $(xdg-user-dir DOCUMENTS)/Assistant, read once.
+ property string outDir: (Quickshell.env("HOME") || "") + "/Documents/Assistant"
+ readonly property string fileops:
+ decodeURIComponent(Qt.resolvedUrl("fileops").toString().replace(/^file:\/\//, ""))
+ readonly property string mdPrompt: "Rewrite this document as Markdown: keep its wording; "
+ + "use headings, lists and tables where the original has them; transcribe scanned "
+ + "pages. Output only the Markdown."
+
// A staged kind chat does not take holds the question back.
readonly property bool blocked: root.staged.some(f => !Files.chats(f.kind))
@@ -102,6 +118,7 @@ Scope {
const files = paths.filter(p => p !== "").map(Files.file);
if (!files.length) return;
root.fresh();
+ root.clearOp();
root.staged = files;
root.historyOpen = root.pickerOpen = false;
root.open = true;
@@ -114,6 +131,64 @@ Scope {
root.refine();
}
+ function clearOp() {
+ if (root.opRunning) return;
+ root.opLabel = root.opError = "";
+ root.opOut = [];
+ root.opChars = 0;
+ }
+
+ function runOp(o) {
+ if (root.opRunning || !root.staged.length) return;
+ root.clearOp();
+ root.opLabel = o.label;
+ root.opRunning = true;
+ const paths = root.staged.map(f => f.path);
+ if (o.op === "md") {
+ if (!root.connected) { root.opRunning = false; root.opError = "assistant not running"; return; }
+ root.opDaemon = true;
+ const name = root.staged[0].name.replace(/\.[^.]*$/, "") + ".md";
+ root.send({ type: "write", files: paths, prompt: root.mdPrompt, dir: root.outDir, name: name });
+ } else {
+ root.opDaemon = false;
+ ops.command = [root.fileops, o.op, root.outDir].concat(paths);
+ ops.running = false;
+ ops.running = true;
+ }
+ }
+
+ function stopOp() {
+ if (!root.opRunning) return;
+ if (root.opDaemon) root.send({ type: "stop" });
+ else ops.running = false; // SIGTERM: fileops kills its tool, removes the partial
+ root.opRunning = false;
+ root.opError = "stopped";
+ }
+
+ Process {
+ id: ops
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (!root.opRunning || root.opDaemon) return;
+ root.opRunning = false;
+ try {
+ const r = JSON.parse(text.trim().split("\n").pop());
+ if (r.ok) root.opOut = r.out; else root.opError = r.error;
+ } catch (e) {
+ root.opError = "fileops gave no answer";
+ }
+ }
+ }
+ }
+
+ Process {
+ running: true
+ command: ["xdg-user-dir", "DOCUMENTS"]
+ stdout: StdioCollector {
+ onStreamFinished: if (text.trim().startsWith("/")) root.outDir = text.trim() + "/Assistant"
+ }
+ }
+
// A file whose name says nothing (a script with no suffix) is "other"
// until file(1) has read it. One run for all of them; the daemon makes
// its own call on the content when the question is sent.
@@ -244,7 +319,16 @@ Scope {
if (!e.stopped) root.convId = e.id;
root.lastActive = Date.now();
break;
+ case "progress": if (root.opDaemon) root.opChars = e.chars; break;
+ case "written":
+ if (root.opDaemon && root.opRunning) { root.opRunning = false; root.opOut = [e.path]; }
+ break;
case "error":
+ if (e.turn && root.opDaemon && root.opRunning) {
+ root.opRunning = false;
+ root.opError = e.message;
+ break;
+ }
// Only an error marked turn ended the reply. A command error,
// perhaps another client's, leaves a streaming reply alone.
if (e.turn && i >= 0 && turns.get(i).live) {
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index f63ce59..02ee476 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -9,10 +9,12 @@
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
+import Quickshell
import QtQuick
import ".."
import "Files.js" as Files
import "Presets.js" as Presets
+import "Ops.js" as Ops
// The card in its three states (Assistant.qml, mode): entry is the input
// line alone; file adds the staged chips and what can be done with them;
@@ -33,6 +35,8 @@ Rectangle {
height: card.tall ? card.fullHeight
: footer.height + 28 + (chips.visible ? chips.height + 10 : 0)
+ (asks.visible ? asks.height + 10 : 0)
+ + (dos.visible ? dos.height + 10 : 0)
+ + (status.visible ? status.height + 10 : 0)
+ (actions.visible ? actions.height + 10 : 0)
Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
clip: true
@@ -153,7 +157,8 @@ Rectangle {
id: chips
visible: card.canvas.staged.length > 0
anchors {
- bottom: asks.visible ? asks.top : actions.visible ? actions.top : footer.top
+ bottom: asks.visible ? asks.top : dos.visible ? dos.top : status.visible ? status.top
+ : actions.visible ? actions.top : footer.top
bottomMargin: 10
left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
}
@@ -198,15 +203,14 @@ Rectangle {
// --- what to do with them (file) ---
// Ask: canned questions matching the staged kinds (Presets.js). A click
- // sends one; Shift+click puts it in the input to edit first. The Do row
- // (local operations) is its own spec and lands here too.
+ // sends one; Shift+click puts it in the input to edit first.
Flow {
id: asks
readonly property var offered: Presets.offered(Presets.list(Prefs.presets), card.canvas.staged)
- visible: card.mode === "file" && offered.length > 0
+ visible: card.mode === "file" && offered.length > 0 && !card.canvas.opRunning
anchors {
- bottom: actions.top; bottomMargin: 10
+ bottom: dos.visible ? dos.top : status.visible ? status.top : actions.top; bottomMargin: 10
left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
}
spacing: 8
@@ -230,6 +234,77 @@ Rectangle {
}
}
+ // Do: local operations (Ops.js, run by fileops or the daemon's write).
+ // Compress… swaps the row for its formats. Hidden while one runs.
+ property bool compressing: false
+ Connections { target: card.canvas; function onStagedChanged() { card.compressing = false; } }
+
+ Flow {
+ id: dos
+ readonly property var offered: card.compressing
+ ? Ops.COMPRESS.concat([{ label: "Back", op: "back" }])
+ : Ops.offered(card.canvas.staged)
+ visible: card.mode === "file" && offered.length > 0 && !card.canvas.opRunning
+ anchors {
+ bottom: status.visible ? status.top : actions.top; bottomMargin: 10
+ left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
+ }
+ spacing: 8
+
+ Repeater {
+ model: dos.offered
+ Button {
+ required property var modelData
+ text: modelData.label
+ // Local operations need no daemon; To .md does.
+ enabled: modelData.op !== "md" || card.canvas.connected
+ onClicked: {
+ if (modelData.op === "compress") card.compressing = true;
+ else if (modelData.op === "back") card.compressing = false;
+ else { card.compressing = false; card.canvas.runOp(modelData); }
+ }
+ }
+ }
+ }
+
+ // What the operation is doing, or what it made.
+ Row {
+ id: status
+ readonly property var c: card.canvas
+ visible: card.mode === "file" && (c.opRunning || c.opError !== "" || c.opOut.length > 0)
+ anchors { bottom: actions.top; bottomMargin: 10; left: parent.left; leftMargin: 14 }
+ spacing: 10
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: status.c.opRunning
+ ? (status.c.opDaemon && status.c.opChars
+ ? `${status.c.opLabel}… ${(status.c.opChars / 1000).toFixed(1)}k characters`
+ : `Working on ${status.c.opLabel}…`)
+ : status.c.opError !== "" ? status.c.opError
+ : status.c.opOut.length === 1 ? `Saved ${status.c.opOut[0].slice(status.c.opOut[0].lastIndexOf("/") + 1)}`
+ : `Saved ${status.c.opOut.length} files`
+ color: status.c.opError !== "" && !status.c.opRunning ? Theme.red : Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Button {
+ visible: status.c.opRunning
+ text: "Stop"
+ danger: true
+ onClicked: status.c.stopOp()
+ }
+ Button {
+ visible: !status.c.opRunning && status.c.opOut.length === 1
+ text: "Open"
+ onClicked: Quickshell.execDetached(["xdg-open", status.c.opOut[0]])
+ }
+ Button {
+ visible: !status.c.opRunning && status.c.opOut.length > 0
+ text: "Folder"
+ onClicked: Quickshell.execDetached(["xdg-open", status.c.outDir])
+ }
+ }
+
Row {
id: actions
visible: card.mode === "file"
diff --git a/desktop/assistant/Ops.js b/desktop/assistant/Ops.js
new file mode 100644
index 0000000..e59560d
--- /dev/null
+++ b/desktop/assistant/Ops.js
@@ -0,0 +1,53 @@
+// 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.
+
+.pragma library
+
+// The file state's Do row. op is a fileops operation, or "md" for the
+// daemon's write, or "compress", which opens the COMPRESS formats. kinds
+// lists the staged kinds it fits ("any" fits every mix); min is the number
+// of files it needs; format is the suffix a file already has when the
+// operation would not change it.
+const OPS = [
+ { label: "To .md", op: "md", kinds: ["pdf"] },
+ { label: "To images", op: "pdf-images", kinds: ["pdf"] },
+ { label: "Merge", op: "pdf-merge", kinds: ["pdf"], min: 2 },
+ { label: "Extract audio", op: "audio", kinds: ["video"] },
+ { label: "To MP4", op: "mp4", kinds: ["video"], format: [".mp4"] },
+ { label: "To WebM", op: "webm", kinds: ["video"], format: [".webm"] },
+ { label: "To GIF", op: "gif", kinds: ["video"] },
+ { label: "Trackcrop", op: "trackcrop", kinds: ["video"] },
+ { label: "To PNG", op: "png", kinds: ["image"], format: [".png"] },
+ { label: "To JPEG", op: "jpeg", kinds: ["image"], format: [".jpg", ".jpeg"] },
+ { label: "To WebP", op: "webp", kinds: ["image"], format: [".webp"] },
+ { label: "Resize 50%", op: "half", kinds: ["image"] },
+ { label: "Extract", op: "extract", kinds: ["archive"] },
+ { label: "Compress…", op: "compress", kinds: ["any"] },
+];
+
+const COMPRESS = [
+ { label: ".zip", op: "zip" },
+ { label: ".tar.gz", op: "tgz" },
+ { label: ".tar.xz", op: "txz" },
+ { label: ".tar.bz2", op: "tbz2" },
+];
+
+function ext(name) {
+ const n = name.toLowerCase(), dot = n.lastIndexOf(".");
+ return dot > 0 ? n.slice(dot) : "";
+}
+
+function offered(files) {
+ if (!files.length) return [];
+ return OPS.filter(o => (o.kinds.includes("any") || files.every(f => o.kinds.includes(f.kind)))
+ && files.length >= (o.min ?? 1)
+ && !(o.format && files.every(f => o.format.includes(ext(f.name)))));
+}
diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md
index 8c74343..41c01b3 100644
--- a/desktop/assistant/README.md
+++ b/desktop/assistant/README.md
@@ -21,6 +21,8 @@ what is typed. The protocol is specified in that repo,
| `Md.js` | splits a message into prose and fenced code |
| `Files.js` | a path's kind, and a dropped URL to a path |
| `Presets.js` | the Ask row's canned questions and which kinds they fit |
+| `Ops.js` | the Do row's operations and which kinds they fit |
+| `fileops` | runs a Do operation: `fileops <op> <outdir> <file...>`, one JSON line back |
Three states (`Assistant.qml`, `mode`): **entry**, one line, "How can I
help you?"; **file**, files staged and nothing asked yet, with what can be
@@ -74,5 +76,13 @@ Notes:
`Prefs.setPresets`. Each prompt asks for an answer in the document's
language, since the model answers in the language it is asked in.
+- **Do operations** write to `$(xdg-user-dir DOCUMENTS)/Assistant`, under
+ the first free name, through a temporary file renamed on success; Stop
+ sends SIGTERM and fileops kills its tool's process group and removes the
+ partial result. To .md goes through the daemon's `write` instead. This
+ ffmpeg has no libx264 and no AAC encoder: To MP4 is `h264_vaapi` with MP3
+ audio, and Extract audio copies AAC, MP3 and Opus tracks as they are.
+
Tests: `node desktop/assistant/test_md.js`, `test_files.js`,
-`test_presets.js` (same directory).
+`test_presets.js`, `test_ops.js`, and `/usr/bin/python3
+desktop/assistant/test_fileops.py` (same directory).
diff --git a/desktop/assistant/test_ops.js b/desktop/assistant/test_ops.js
new file mode 100644
index 0000000..6d53376
--- /dev/null
+++ b/desktop/assistant/test_ops.js
@@ -0,0 +1,45 @@
+// 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.
+
+// Run: node desktop/assistant/test_ops.js
+// Ops.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Ops.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const O = new Function(src + "\nreturn { offered, COMPRESS };")();
+
+const f = (name, kind) => ({ path: "/x/" + name, name, kind });
+const labels = files => O.offered(files).map(o => o.label);
+
+assert.deepStrictEqual(labels([f("a.pdf", "pdf")]), ["To .md", "To images", "Compress…"]);
+assert.deepStrictEqual(labels([f("a.pdf", "pdf"), f("b.pdf", "pdf")]),
+ ["To .md", "To images", "Merge", "Compress…"]);
+assert.deepStrictEqual(labels([f("a.pdf", "pdf"), f("p.png", "image")]), ["Compress…"]);
+// An image's own format is not offered.
+assert.deepStrictEqual(labels([f("p.png", "image")]), ["To JPEG", "To WebP", "Resize 50%", "Compress…"]);
+assert.deepStrictEqual(labels([f("p.JPG", "image")]), ["To PNG", "To WebP", "Resize 50%", "Compress…"]);
+// Mixed formats: offered while any file would change.
+assert.deepStrictEqual(labels([f("p.png", "image"), f("q.jpg", "image")]),
+ ["To PNG", "To JPEG", "To WebP", "Resize 50%", "Compress…"]);
+assert.deepStrictEqual(labels([f("c.mp4", "video")]),
+ ["Extract audio", "To WebM", "To GIF", "Trackcrop", "Compress…"]);
+assert.deepStrictEqual(labels([f("c.mkv", "video")]),
+ ["Extract audio", "To MP4", "To WebM", "To GIF", "Trackcrop", "Compress…"]);
+assert.deepStrictEqual(labels([f("b.tar.gz", "archive")]), ["Extract", "Compress…"]);
+assert.deepStrictEqual(labels([f("n.txt", "text")]), ["Compress…"]);
+assert.deepStrictEqual(labels([]), []);
+assert.deepStrictEqual(O.COMPRESS.map(c => c.op), ["zip", "tgz", "txz", "tbz2"]);
+
+console.log("ok");