From f6ceae477979b32869c0c17e2b1fc05477d6eb81 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 16:06:33 +0200 Subject: feat(assistant): Ask presets in the file state A row of canned questions matching every staged file's kind, plus Compare for two or more files: one click asks it, Shift+click puts it in the input to edit first. Defaults live in Presets.js and the list in use in Prefs, so a future drawer settings page can edit it; a saved list that does not validate reads as the defaults. Each prompt asks for an answer in the document's language. Code and configuration get their own kind, so a script is offered Explain and Review rather than Summarize. Button's clicked signal carries the mouse event for the Shift check. Co-Authored-By: Claude Opus 5.5 --- desktop/Button.qml | 6 +++-- desktop/Prefs.qml | 21 +++++++++++++++++- desktop/assistant/Card.qml | 39 ++++++++++++++++++++++++++++++--- desktop/assistant/Files.js | 10 ++++++--- desktop/assistant/Presets.js | 46 +++++++++++++++++++++++++++++++++++++++ desktop/assistant/README.md | 11 ++++++++-- desktop/assistant/test_files.js | 5 +++-- desktop/assistant/test_presets.js | 46 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 desktop/assistant/Presets.js create mode 100644 desktop/assistant/test_presets.js diff --git a/desktop/Button.qml b/desktop/Button.qml index b93a926..6a977fa 100644 --- a/desktop/Button.qml +++ b/desktop/Button.qml @@ -17,7 +17,9 @@ Rectangle { property alias font: label.font property bool danger: false property bool enabled: true - signal clicked + // The mouse event, for a caller that reads modifiers; a handler that + // takes no argument ignores it. + signal clicked(var mouse) implicitWidth: label.implicitWidth + 22 implicitHeight: label.implicitHeight + 12 @@ -44,6 +46,6 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: btn.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (btn.enabled) btn.clicked() + onClicked: mouse => { if (btn.enabled) btn.clicked(mouse); } } } diff --git a/desktop/Prefs.qml b/desktop/Prefs.qml index fa0dde9..def720c 100644 --- a/desktop/Prefs.qml +++ b/desktop/Prefs.qml @@ -31,6 +31,10 @@ Singleton { // The public IP the user pinned from the performance panel; "" for none. property string pinnedIp: "" + // The assistant's Ask presets as saved, or null for the defaults + // (assistant/Presets.js, which also validates what is read here). + property var presets: null + // The whole parsed object, so a write keeps keys this version does not // know about. property var doc: ({}) @@ -42,6 +46,19 @@ Singleton { root.write(Object.assign({}, root.doc, { perf: perf })); } + // For the settings page that will edit the presets. + function setPresets(list) { + root.presets = list; + const a = Object.assign({}, root.doc.assistant, { presets: list }); + root.write(Object.assign({}, root.doc, { assistant: a })); + } + function resetPresets() { + root.presets = null; + const a = Object.assign({}, root.doc.assistant); + delete a.presets; + root.write(Object.assign({}, root.doc, { assistant: a })); + } + function write(out) { root.doc = out; file.setText(JSON.stringify(out, null, 2) + "\n"); @@ -56,6 +73,8 @@ Singleton { root.doc = parsed; const pin = parsed.perf && typeof parsed.perf.pinnedIp === "string" ? parsed.perf.pinnedIp : ""; if (pin !== root.pinnedIp) root.pinnedIp = pin; + root.presets = parsed.assistant && parsed.assistant.presets !== undefined + ? parsed.assistant.presets : null; } FileView { @@ -70,6 +89,6 @@ Singleton { onFileChanged: reload() onLoaded: root.reparse() // Missing file: the defaults, and nothing written. - onLoadFailed: { root.doc = {}; root.pinnedIp = ""; } + onLoadFailed: { root.doc = {}; root.pinnedIp = ""; root.presets = null; } } } diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml index 228cfdb..f63ce59 100644 --- a/desktop/assistant/Card.qml +++ b/desktop/assistant/Card.qml @@ -12,6 +12,7 @@ import QtQuick import ".." import "Files.js" as Files +import "Presets.js" as Presets // 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; @@ -31,6 +32,7 @@ Rectangle { // Bottom-up: the input, then the actions (file state), then the chips. height: card.tall ? card.fullHeight : footer.height + 28 + (chips.visible ? chips.height + 10 : 0) + + (asks.visible ? asks.height + 10 : 0) + (actions.visible ? actions.height + 10 : 0) Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } clip: true @@ -151,7 +153,8 @@ Rectangle { id: chips visible: card.canvas.staged.length > 0 anchors { - bottom: actions.visible ? actions.top : footer.top; bottomMargin: 10 + bottom: asks.visible ? asks.top : actions.visible ? actions.top : footer.top + bottomMargin: 10 left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 } spacing: 6 @@ -194,8 +197,38 @@ Rectangle { } // --- what to do with them (file) --- - // Piece 1 offers Chat about it; the Ask and Do rows (model prompts and - // local operations) are their own specs and land here. + // 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. + + Flow { + id: asks + readonly property var offered: Presets.offered(Presets.list(Prefs.presets), card.canvas.staged) + visible: card.mode === "file" && offered.length > 0 + anchors { + bottom: actions.top; bottomMargin: 10 + left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 + } + spacing: 8 + + Repeater { + model: asks.offered + Button { + required property var modelData + text: modelData.label + enabled: card.canvas.connected && !card.canvas.blocked + onClicked: mouse => { + if (mouse && (mouse.modifiers & Qt.ShiftModifier)) { + input.text = Presets.prompt(modelData); + input.cursorPosition = input.text.length; + input.forceActiveFocus(); + } else { + card.canvas.ask(Presets.prompt(modelData)); + } + } + } + } + } Row { id: actions diff --git a/desktop/assistant/Files.js b/desktop/assistant/Files.js index bcfe1f0..825aed1 100644 --- a/desktop/assistant/Files.js +++ b/desktop/assistant/Files.js @@ -17,17 +17,20 @@ // ponytail: the daemon also takes any text/* mime type; a file of such a // type with a suffix not listed here reads as "other" and is not offered // chat. Add the suffix when one shows up. -const TEXT = [".py", ".c", ".h", ".cpp", ".hpp", ".rs", ".go", ".js", ".ts", ".jsx", +// Text splits into prose and data (text) and code or configuration (code), +// so each gets the presets that fit it; the daemon sees one text kind. +const TEXT = [".md", ".rst", ".txt", ".csv"]; +const CODE = [".py", ".c", ".h", ".cpp", ".hpp", ".rs", ".go", ".js", ".ts", ".jsx", ".tsx", ".sh", ".bash", ".zsh", ".lua", ".rb", ".pl", ".php", ".java", ".kt", ".swift", ".sql", ".toml", ".yaml", ".yml", ".json", ".ini", - ".cfg", ".conf", ".md", ".rst", ".txt", ".csv", ".xml", ".html", ".css", + ".cfg", ".conf", ".xml", ".html", ".css", ".diff", ".patch", ".slackbuild", ".info", ".desktop", ".service", ".qml"]; const IMAGE = [".png", ".jpg", ".jpeg", ".gif", ".webp"]; const VIDEO = [".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v"]; const ARCHIVE = [".zip", ".tar", ".gz", ".tgz", ".xz", ".txz", ".bz2", ".zst", ".7z", ".rar"]; // Kinds chat takes; the rest only get local actions. -const CHAT = ["text", "image", "pdf"]; +const CHAT = ["text", "code", "image", "pdf"]; function name(path) { return path.slice(path.lastIndexOf("/") + 1); } @@ -40,6 +43,7 @@ function kind(path) { if (VIDEO.includes(ext)) return "video"; if (ARCHIVE.includes(ext)) return "archive"; if (TEXT.includes(ext)) return "text"; + if (CODE.includes(ext)) return "code"; return "other"; } diff --git a/desktop/assistant/Presets.js b/desktop/assistant/Presets.js new file mode 100644 index 0000000..116d7d0 --- /dev/null +++ b/desktop/assistant/Presets.js @@ -0,0 +1,46 @@ +// 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 + +// Canned questions for the file state's Ask row. kinds lists the staged +// kinds a preset applies to (Files.js); ["many"] offers it whenever two or +// more files are staged. The list in use is Prefs.presets when one is +// saved (a settings page will edit it), these otherwise. +const DEFAULTS = [ + { label: "Summarize", kinds: ["pdf", "text"], prompt: "Summarize this in a few short paragraphs." }, + { label: "Key points", kinds: ["pdf"], prompt: "List the key points as bullets." }, + { label: "Explain simply", kinds: ["pdf", "text"], prompt: "Explain this simply, as to a non-expert." }, + { label: "Describe", kinds: ["image"], prompt: "Describe this image." }, + { label: "Extract text", kinds: ["image", "pdf"], prompt: "Transcribe all the text, keeping the layout where it helps." }, + { label: "What is this?", kinds: ["image"], prompt: "What is this, and what is it for?" }, + { label: "Explain", kinds: ["code"], prompt: "Explain what this code does." }, + { label: "Review", kinds: ["code"], prompt: "Review this code: bugs, risks, and simplifications." }, + { label: "Compare", kinds: ["many"], prompt: "Compare these files: what they share and how they differ." }, +]; + +// The saved list if it is one, else the defaults: a hand edit with a typo +// must not leave the row empty. +function list(saved) { + const ok = Array.isArray(saved) && saved.every(p => p && typeof p.label === "string" + && typeof p.prompt === "string" && Array.isArray(p.kinds)); + return ok ? saved : DEFAULTS; +} + +function offered(presets, files) { + if (!files.length) return []; + return presets.filter(p => p.kinds.includes("many") ? files.length > 1 + : files.every(f => p.kinds.includes(f.kind))); +} + +// The model answers in the language it is asked in; a preset is asked in +// English, so it says which language to use. +function prompt(p) { return p.prompt + " Answer in the language of the document."; } diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md index aabce2d..4715b12 100644 --- a/desktop/assistant/README.md +++ b/desktop/assistant/README.md @@ -20,6 +20,7 @@ what is typed. The protocol is specified in that repo, | `Picker.qml` | the in-card file picker behind the paperclip | | `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 | Three states (`Assistant.qml`, `mode`): **entry**, one line, "How can I help you?"; **file**, files staged and nothing asked yet, with what can be @@ -65,5 +66,11 @@ Notes: - **`socketPath`** is a property so a check can point the canvas at a scratch daemon; the desktop uses the default. -Test the splitter and the kinds: `node desktop/assistant/test_md.js`, -`node desktop/assistant/test_files.js`. +- **Ask presets** live in `Prefs.qml` (`assistant.presets`) once saved and + are `Presets.js`'s defaults until then; an invalid saved list reads as the + defaults. There is no editor yet: a drawer settings page will call + `Prefs.setPresets`. Each prompt asks for an answer in the document's + language, since the model answers in the language it is asked in. + +Tests: `node desktop/assistant/test_md.js`, `test_files.js`, +`test_presets.js` (same directory). diff --git a/desktop/assistant/test_files.js b/desktop/assistant/test_files.js index 5ece64f..80a855d 100644 --- a/desktop/assistant/test_files.js +++ b/desktop/assistant/test_files.js @@ -23,12 +23,13 @@ const F = new Function(src + "\nreturn { kind, chats, fromUrl, file, name };")() const cases = { "/a/report.pdf": "pdf", "/a/Report.PDF": "pdf", "/a/x.png": "image", "/a/x.JPEG": "image", "/a/clip.mp4": "video", "/a/c.MKV": "video", "/a/src.tar.gz": "archive", "/a/b.7z": "archive", - "/a/main.py": "text", "/a/notes.md": "text", "/a/pkg.SlackBuild": "text", + "/a/main.py": "code", "/a/notes.md": "text", "/a/pkg.SlackBuild": "code", + "/a/data.csv": "text", "/a/README.rst": "text", "/a/a.txt": "text", "/a/c.json": "code", "/a/Makefile": "other", "/a/.bashrc": "other", "/a/x.bin": "other", "/a/dir.d/noext": "other", }; for (const [p, k] of Object.entries(cases)) assert.strictEqual(F.kind(p), k, p); -assert.ok(F.chats("pdf") && F.chats("image") && F.chats("text")); +assert.ok(F.chats("pdf") && F.chats("image") && F.chats("text") && F.chats("code")); assert.ok(!F.chats("video") && !F.chats("archive") && !F.chats("other")); assert.strictEqual(F.fromUrl("file:///home/you/My%20Docs/caf%C3%A9.pdf"), "/home/you/My Docs/café.pdf"); diff --git a/desktop/assistant/test_presets.js b/desktop/assistant/test_presets.js new file mode 100644 index 0000000..e4a18f4 --- /dev/null +++ b/desktop/assistant/test_presets.js @@ -0,0 +1,46 @@ +// 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_presets.js +// Presets.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 + "/Presets.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const P = new Function(src + "\nreturn { DEFAULTS, list, offered, prompt };")(); + +const files = (...kinds) => kinds.map((kind, i) => ({ path: `/f${i}`, name: `f${i}`, kind })); +const labels = staged => P.offered(P.DEFAULTS, staged).map(p => p.label); + +assert.deepStrictEqual(labels(files("pdf")), ["Summarize", "Key points", "Explain simply", "Extract text"]); +assert.deepStrictEqual(labels(files("image", "pdf")), ["Extract text", "Compare"]); +assert.deepStrictEqual(labels(files("code", "code")), ["Explain", "Review", "Compare"]); +assert.deepStrictEqual(labels(files("code")), ["Explain", "Review"]); +assert.deepStrictEqual(labels(files("text")), ["Summarize", "Explain simply"]); +assert.deepStrictEqual(labels(files("image")), ["Describe", "Extract text", "What is this?"]); +assert.deepStrictEqual(labels(files("video")), []); +assert.deepStrictEqual(labels([]), []); + +// Every prompt asks for the document's language. +for (const p of P.DEFAULTS) + assert.ok(P.prompt(p).endsWith(" Answer in the language of the document."), p.label); + +// A saved list is used when valid; anything else reads as the defaults. +const mine = [{ label: "Mine", prompt: "Do it.", kinds: ["pdf"] }]; +assert.strictEqual(P.list(mine), mine); +for (const bad of [null, undefined, {}, "x", [{ label: "No kinds", prompt: "p" }], + [{ label: 3, prompt: "p", kinds: [] }], [null]]) + assert.strictEqual(P.list(bad), P.DEFAULTS, JSON.stringify(bad)); + +console.log("ok"); -- cgit v1.2.3