From 607934e74ffabc1dcce72b29fc28e92a669e54ce Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 16:41:51 +0200 Subject: feat(assistant): the Do row of file operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversions, extraction, merge and compression on the staged files from the file state, one at a time, with Stop; Compress… opens its formats. To .md goes through the daemon's write, the rest through fileops. The card keeps the result line, Saved name with Open and Folder, or the error, so another operation can follow on the same files. Results land in Documents/Assistant. Co-Authored-By: Claude Opus 5.5 --- desktop/assistant/Assistant.qml | 84 ++++++++++++++++++++++++++++++++++++++++ desktop/assistant/Card.qml | 85 ++++++++++++++++++++++++++++++++++++++--- desktop/assistant/Ops.js | 53 +++++++++++++++++++++++++ desktop/assistant/README.md | 12 +++++- desktop/assistant/test_ops.js | 45 ++++++++++++++++++++++ 5 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 desktop/assistant/Ops.js create mode 100644 desktop/assistant/test_ops.js (limited to 'desktop/assistant') 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. +// +// 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 `, 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. +// +// 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"); -- cgit v1.2.3