diff options
Diffstat (limited to 'desktop')
| -rw-r--r-- | desktop/assistant/Assistant.qml | 84 | ||||
| -rw-r--r-- | desktop/assistant/Card.qml | 85 | ||||
| -rw-r--r-- | desktop/assistant/Ops.js | 53 | ||||
| -rw-r--r-- | desktop/assistant/README.md | 12 | ||||
| -rw-r--r-- | desktop/assistant/test_ops.js | 45 |
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"); |
