diff options
Diffstat (limited to 'desktop')
26 files changed, 1808 insertions, 107 deletions
diff --git a/desktop/Prefs.qml b/desktop/Prefs.qml index 6340b91..610e004 100644 --- a/desktop/Prefs.qml +++ b/desktop/Prefs.qml @@ -56,6 +56,55 @@ Singleton { root.write(Object.assign({}, root.doc, { companion: on })); } + // Seconds the companion stays in her leotard after the self-destruct, + // 0 for until she is hidden and shown again. + property int companionSkirtless: 60 + function setCompanionSkirtless(s) { + if (s === root.companionSkirtless) return; + root.companionSkirtless = s; + root.write(Object.assign({}, root.doc, { companionSkirtless: s })); + } + + // The companion's wallpaper list, "" for the default in + // companion/Admired.qml. + property string companionBlushFile: "" + function setCompanionBlushFile(p) { + p = p.trim(); + if (p === root.companionBlushFile) return; + root.companionBlushFile = p; + root.write(Object.assign({}, root.doc, { companionBlushFile: p })); + } + + // The companion's app lines as saved: a map of lowercased window class + // to phrases, or null for the defaults in companion/Lines.js. The + // Companion phrases page edits it. + property var companionApps: null + function setCompanionApps(map) { + root.companionApps = map; + root.write(Object.assign({}, root.doc, { companionApps: map })); + } + function resetCompanionApps() { + root.companionApps = null; + const d = Object.assign({}, root.doc); + delete d.companionApps; + root.write(d); + } + + // The companion's artist lines as saved, keyed by lowercased artist, or + // null for the defaults in companion/Lines.js. The Companion artists + // page edits it. + property var companionArtists: null + function setCompanionArtists(map) { + root.companionArtists = map; + root.write(Object.assign({}, root.doc, { companionArtists: map })); + } + function resetCompanionArtists() { + root.companionArtists = null; + const d = Object.assign({}, root.doc); + delete d.companionArtists; + root.write(d); + } + // The settings window's sidebar width, dragged by its edge. readonly property int sidebarMin: 160 readonly property int sidebarMax: 420 @@ -115,6 +164,13 @@ Singleton { root.settingsSidebar = typeof w === "number" && w >= root.sidebarMin && w <= root.sidebarMax ? Math.round(w) : 200; root.companion = parsed.companion !== false; + const sk = parsed.companionSkirtless; + root.companionSkirtless = Number.isInteger(sk) && sk >= 0 ? sk : 60; + root.companionBlushFile = typeof parsed.companionBlushFile === "string" ? parsed.companionBlushFile : ""; + const ca = parsed.companionApps; + root.companionApps = ca && typeof ca === "object" && !Array.isArray(ca) ? ca : null; + const cr = parsed.companionArtists; + root.companionArtists = cr && typeof cr === "object" && !Array.isArray(cr) ? cr : null; root.ready = true; } @@ -130,6 +186,6 @@ Singleton { onFileChanged: reload() onLoaded: root.reparse() // Missing file: the defaults, and nothing written. - onLoadFailed: { root.doc = {}; root.pinnedIp = ""; root.presets = null; root.settingsSidebar = 200; root.assistantModel = ""; root.companion = true; root.ready = true; } + onLoadFailed: { root.doc = {}; root.pinnedIp = ""; root.presets = null; root.settingsSidebar = 200; root.assistantModel = ""; root.companion = true; root.companionBlushFile = ""; root.companionSkirtless = 60; root.companionApps = null; root.companionArtists = null; root.ready = true; } } } diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml index 2641a5e..0b7026d 100644 --- a/desktop/assistant/Assistant.qml +++ b/desktop/assistant/Assistant.qml @@ -66,6 +66,18 @@ Scope { // The preferred model being loaded on open, shown in the header until // the router reports it loaded. Set from shell.qml. property string swapping: "" + // The model this chat is asked with: its stored pick (hello.chat_model, + // conversation.model), "" for none, then the preferred one (set from + // shell.qml). "" for both: the daemon uses whatever the router has + // loaded. Spec: desktop-assistant, + // docs/superpowers/specs/2026-10-23-cloud-models-design.md. + property string chatModel: "" + property string preferred: "" + readonly property string wanted: root.chatModel || root.preferred + // The daemon's models event: [{id, status, cloud: false} | {id, provider, cloud: true}]. + property var models: [] + readonly property bool cloud: root.models.some(m => m.cloud && m.id === root.wanted) + property bool modelsOpen: false property int ctxUsed: 0 property int ctxMax: 0 property var convId: null @@ -113,11 +125,12 @@ Scope { function close() { root.endRecording(false); - root.open = false; root.historyOpen = false; root.pickerOpen = false; + root.open = false; root.historyOpen = false; root.pickerOpen = false; root.modelsOpen = false; } // Escape: a recording, the picker, then the history panel, then the card. function back() { if (root.recording) root.endRecording(false); + else if (root.modelsOpen) root.modelsOpen = false; else if (root.pickerOpen) root.pickerOpen = false; else if (root.historyOpen) root.historyOpen = false; else root.close(); @@ -258,8 +271,8 @@ Scope { root.notice = "recording failed"; } else if (rec.send && root.connected) { root.chatting = true; - root.send({ type: "ask", text: "", audio: root.micPath, - files: root.staged.map(f => f.path) }); + root.send(root.withModel({ type: "ask", text: "", audio: root.micPath, + files: root.staged.map(f => f.path) })); } rec.destroy(); } @@ -325,16 +338,37 @@ Scope { || (text.trim() === "" && !root.staged.length)) return false; root.notice = ""; root.chatting = true; - root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) }); + root.send(root.withModel({ type: "ask", text: text, files: root.staged.map(f => f.path) })); return true; } function showHistory() { root.pickerOpen = false; + root.modelsOpen = false; root.historyOpen = true; root.send({ type: "list" }); } + function showModels() { + root.historyOpen = root.pickerOpen = false; + root.modelsOpen = true; + root.send({ type: "models" }); + } + // Applies from the next ask; the daemon stores it with the chat then. + // Moving a chat with turns to the cloud sends its history there, so + // that is said once, on the switch. + function pickModel(id) { + const m = root.models.find(x => x.id === id); + if (m && m.cloud && !root.cloud && turns.count) + root.notice = `History in this chat goes to ${m.provider}.`; + root.chatModel = id; + root.modelsOpen = false; + } + function withModel(cmd) { + if (root.wanted) cmd.model = root.wanted; + return cmd; + } + function row(t, live) { return { user: t.user ?? "", reply: t.reply ?? "", think: t.think ?? "", tools: (t.tools ?? []).join(","), error: "", live: live, @@ -371,15 +405,18 @@ Scope { root.state = e.state; root.runningTool = e.state === "tool" ? e.tool : ""; root.model = e.model ?? ""; + root.chatModel = e.chat_model ?? ""; root.ctxUsed = e.ctx_used; root.ctxMax = e.ctx_max; root.load(e.id, e.turns, e.pending); // A restart or reload counts as active: the conversation it // reconnects to is not stale just because this process is new. if (e.pending || e.turns.length) root.lastActive = Date.now(); + root.send({ type: "models" }); break; case "conversation": root.load(e.id, e.turns, null); + root.chatModel = e.model ?? ""; // Opened from History on purpose: it counts as active now. if (e.turns.length) root.lastActive = Date.now(); // Emptied (New, or the current one deleted) with nothing staged: @@ -445,6 +482,7 @@ Scope { case "ctx": root.ctxUsed = e.used; root.ctxMax = e.max; if (e.model) root.model = e.model; break; case "conversations": root.convs = e.items; break; case "results": root.results = e.items; break; + case "models": root.models = e.items; break; } } diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml index a2f8e28..810920f 100644 --- a/desktop/assistant/Card.qml +++ b/desktop/assistant/Card.qml @@ -28,7 +28,7 @@ Rectangle { property real fullHeight: 600 // the chat state's height, set by the window readonly property string mode: card.canvas.mode - readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen + readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen || card.canvas.modelsOpen readonly property bool tall: card.mode === "chat" || card.panel // Bottom-up: the input, then the actions (file state), then the chips. @@ -49,13 +49,15 @@ Rectangle { // Keys reach a focused item, never the window (AGENTS.md); the card is // the ancestor of every text field, so their Escape bubbles here. Keys.onEscapePressed: card.canvas.back() - // Ctrl+N and Ctrl+H do what the New and History buttons do. + // Ctrl+N, Ctrl+H and Ctrl+M do what New, History and the model menu do. Keys.onPressed: event => { if (!(event.modifiers & Qt.ControlModifier)) return; if (event.key === Qt.Key_N && card.canvas.connected) { card.canvas.fresh(); card.canvas.historyOpen = false; input.forceActiveFocus(); } else if (event.key === Qt.Key_H) { card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory(); + } else if (event.key === Qt.Key_M && card.canvas.connected) { + card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels(); } else return; event.accepted = true; } @@ -90,13 +92,40 @@ Rectangle { anchors { top: parent.top; left: parent.left; right: parent.right; margins: 14 } height: 32 - Text { + // The chat's model, a click opens the model panel. nf-fa-cloud for a + // cloud one, nf-fa-angle_down after it. + Row { + id: modelRow anchors { left: parent.left; verticalCenter: parent.verticalCenter } - text: !card.canvas.connected ? "assistant not running" - : card.canvas.swapping ? `loading ${card.canvas.swapping}…` - : card.canvas.model || "assistant" - color: card.canvas.connected ? Theme.text : Theme.red - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + spacing: 6 + Text { + anchors.verticalCenter: parent.verticalCenter + visible: card.canvas.connected && !card.canvas.swapping && card.canvas.cloud + text: "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: !card.canvas.connected ? "assistant not running" + : card.canvas.swapping ? `loading ${card.canvas.swapping}…` + : card.canvas.wanted || card.canvas.model || "assistant" + color: card.canvas.connected ? Theme.text : Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: card.canvas.connected + text: "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + } + MouseArea { + anchors.fill: modelRow + enabled: card.canvas.connected + cursorShape: Qt.PointingHandCursor + onClicked: card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels() } Row { @@ -436,7 +465,7 @@ Rectangle { text: "" font.family: Theme.iconFamily onClicked: { - card.canvas.historyOpen = false; + card.canvas.historyOpen = card.canvas.modelsOpen = false; card.canvas.pickerOpen = !card.canvas.pickerOpen; } } @@ -466,7 +495,7 @@ Rectangle { id: box anchors { bottom: parent.bottom; left: mic.right; leftMargin: 8 - right: history.left; rightMargin: 8 + right: modelBtn.left; rightMargin: 8 } // Grows with the text up to six lines, then scrolls. height: Math.min(input.contentHeight, 6 * input.font.pixelSize * 1.4) + 16 @@ -524,6 +553,20 @@ Rectangle { } } + // The model menu outside chat, where there is no header; nf-fa-cloud + // when the next question goes to a cloud model. + Button { + id: modelBtn + visible: card.mode !== "chat" + width: visible ? implicitWidth : 0 + anchors { bottom: parent.bottom; right: history.left; rightMargin: visible ? 8 : 0 } + height: box.height + icon: card.canvas.cloud ? "" : "" + text: "Model" + enabled: card.canvas.connected + onClicked: card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels() + } + // History from every state; in chat the header has it too. Button { id: history @@ -571,5 +614,10 @@ Rectangle { visible: card.canvas.pickerOpen canvas: card.canvas } + Models { + anchors.fill: parent + visible: card.canvas.modelsOpen + canvas: card.canvas + } } } diff --git a/desktop/assistant/Models.qml b/desktop/assistant/Models.qml new file mode 100644 index 0000000..b1b2a38 --- /dev/null +++ b/desktop/assistant/Models.qml @@ -0,0 +1,87 @@ +// 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. + +import QtQuick +import ".." + +// The model panel, over the transcript: the router's models, then the +// cloud ones, in the daemon's order. A pick applies to this chat from its +// next question. +Rectangle { + id: panel + property var canvas + + color: Qt.alpha(Theme.base, 0.97) + radius: 8 + + MouseArea { anchors.fill: parent } // keep clicks off the transcript below + + ListView { + id: rows + anchors.fill: parent + clip: true + spacing: 2 + model: panel.canvas.models + + delegate: Rectangle { + id: r + required property var modelData + readonly property bool picked: r.modelData.id === panel.canvas.wanted + width: rows.width + height: 34 + radius: 6 + color: hover.hovered ? Qt.alpha(Theme.surface, 0.9) : "transparent" + border.width: r.picked ? 1 : 0 + border.color: Qt.alpha(Theme.accent, 0.5) + + HoverHandler { id: hover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: panel.canvas.pickModel(r.modelData.id) + } + // nf-fa-cloud, present in Inconsolata Nerd Font. + Text { + id: icon + anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter } + width: 16 + text: r.modelData.cloud ? "" : "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + anchors { left: icon.right; leftMargin: 6; right: stat.left; rightMargin: 8 + verticalCenter: parent.verticalCenter } + // A cloud id without its provider: the right column says it. + text: r.modelData.cloud ? r.modelData.id.slice(r.modelData.provider.length + 1) + : r.modelData.id + elide: Text.ElideRight + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: r.picked } + } + Text { + id: stat + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + text: r.modelData.cloud ? r.modelData.provider : r.modelData.status + color: r.modelData.status === "loaded" ? Theme.green : Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + + Text { + anchors.centerIn: parent + visible: rows.count === 0 + text: "No models: is llama-server running?" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + } +} diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md index 58fbb14..4c76a66 100644 --- a/desktop/assistant/README.md +++ b/desktop/assistant/README.md @@ -18,6 +18,7 @@ what is typed. The protocol is specified in that repo, | `Segments.qml` | markdown with a copy button per code block | | `History.qml` | conversation list and full-text search | | `Picker.qml` | the in-card file picker behind the paperclip | +| `Models.qml` | the model panel: local and cloud models, the chat's pick | | `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 | @@ -38,9 +39,16 @@ model transcribe it and answers the words. The mic is greyed out while the model in use reports no `audio` input (`AssistantModule.hears`, from the router's `/models`, read on every open). +The header's model name (the Model button outside chat, or Ctrl+M) opens +the model panel: the router's models, then each cloud provider's. A pick +applies to this chat from its next question and is stored with it by the +daemon; a new chat starts on the preferred model. Moving a chat with turns +to a cloud model says once that its history goes to that provider. Voice is +transcribed by the local model in a cloud chat too. + Keys: Enter sends, Shift+Enter is a newline, Escape cancels a recording, then closes the picker, the history panel, then the card. Ctrl+N starts a -new chat, Ctrl+H toggles the history panel. Stop replaces +new chat, Ctrl+H toggles the history panel, Ctrl+M the model panel. Stop replaces Send while a reply streams. Notes: diff --git a/desktop/companion/Admired.qml b/desktop/companion/Admired.qml new file mode 100644 index 0000000..eee76d1 --- /dev/null +++ b/desktop/companion/Admired.qml @@ -0,0 +1,101 @@ +// 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 Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import ".." +import "ListFile.js" as LF + +// The wallpapers the companion blushes at, and what wallp has on each +// monitor. The companion reads both; the wallpaper picker toggles entries. +// What is on each monitor is read from wallp's own files here rather than +// through the appearance module's Wallpapers, which scans every wallpaper +// on creation, and the companion runs all session. +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string defaultPath: + (Quickshell.env("XDG_CONFIG_HOME") || root.home + "/.config") + "/keystone/companion-blush.txt" + readonly property string path: + Prefs.companionBlushFile !== "" ? Prefs.companionBlushFile.replace(/^~(?=\/|$)/, root.home) : root.defaultPath + + property string listText: "" + readonly property var paths: LF.parse(root.listText) + function listed(p) { return p !== "" && root.paths.indexOf(p) >= 0; } + + // The wallpaper on a monitor, by connector name; "" for one wallp does + // not set. + property var outputs: ({}) + property string wallH: "" + property string wallV: "" + function on(monitor) { + const w = root.outputs[monitor]; + return w === "H" ? root.wallH : w === "V" ? root.wallV : ""; + } + + // ponytail: a list file created by hand after start may not be watched + // until a restart (the watch is on a path that did not exist); the + // heart writes through the process below, which sets listText itself. + FileView { + path: root.path + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.listText = text() + onLoadFailed: root.listText = "" + } + FileView { + path: root.home + "/.config/wallp/wallp.conf" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.outputs = LF.outputs(text()) + } + FileView { + path: root.home + "/.config/wallp/wall_h" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.wallH = text().trim() + } + FileView { + path: root.home + "/.config/wallp/wall_v" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.wallV = text().trim() + } + + // Through cat rather than FileView.setText, because the list can become + // empty and setText("") on a path the view has not loaded writes no + // file (AGENTS.md). + function toggle(p) { + writer.data = LF.toggle(root.listText, p); + root.listText = writer.data; + writer.command = ["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "sh", root.path]; + writer.stdinEnabled = true; + writer.running = false; + writer.running = true; + } + Process { + id: writer + property string data: "" + onStarted: { + writer.write(writer.data); + writer.stdinEnabled = false; + } + onExited: code => { if (code !== 0) console.warn("Admired: writing", root.path, "failed"); } + } +} diff --git a/desktop/companion/Companion.qml b/desktop/companion/Companion.qml index 12dd009..aa0505d 100644 --- a/desktop/companion/Companion.qml +++ b/desktop/companion/Companion.qml @@ -12,9 +12,11 @@ import Quickshell import Quickshell.Io import Quickshell.Wayland +import Quickshell.Bluetooth import Quickshell.Hyprland import QtQuick import ".." +import "../modules/sound" import "../NotifyConfig.js" as NC import "Mood.js" as Mood import "Lines.js" as Lines @@ -33,13 +35,31 @@ PanelWindow { // SUPER+\ (the IPC below) and the Companion settings page, through Prefs, // so it survives a restart. readonly property bool present: Prefs.ready && Prefs.companion + // A hide plays a goodbye first: the wave frames, then the drop. + // Re-showing her cancels it and greets as usual. + property bool leaving: false + property int waveIndex: 0 + readonly property var waveCycle: ["wave", "wave-l", "wave", "wave-r"] onPresentChanged: { if (root.present) { + root.leaving = false; root.destructAt = 0; // the skirt is back root.now = Date.now(); greet.restart(); } else { root.stopMonologue(); + // Nothing to wave at when she is already out of view. + if (root.fullscreen !== 2 && !root.hopping) { + root.leaving = true; + root.waveIndex = 0; + root.now = Date.now(); + // A line one hide in four, like the monologue's roll, and not + // forced, so the usual 30 s line cooldown still applies. A + // silent wave is only as long as it takes to read nothing. + const spoke = Math.random() < 0.25 && root.say("bye"); + goodbyeTimer.interval = spoke ? Mood.T.goodbye : Mood.T.goodbyeSilent; + goodbyeTimer.start(); + } } } @@ -48,9 +68,13 @@ PanelWindow { readonly property string focused: Hyprland.focusedMonitor?.name ?? "" property string here: "" property bool hopping: false + // No focused monitor (outputs gone over a suspend) is not a destination. + // Already out of view, there is no exit to wait for: settled would never + // fire, hopping would stick and hide her for good, so move at once. onFocusedChanged: { - if (root.here === "") root.here = root.focused; - else if (root.focused !== root.here) root.hopping = true; + if (root.focused === "" || root.focused === root.here) return; + if (root.here === "" || root.away) root.here = root.focused; + else root.hopping = true; } Component.onCompleted: root.here = root.focused @@ -82,9 +106,14 @@ PanelWindow { property double typingAt: 0 property double pulseAt: 0 property double wakeAt: 0 + property double admireAt: 0 + property double singAt: 0 + property double djAt: 0 + property string groove: "" property double lastLineAt: 0 property double lastMonologueAt: 0 property bool monologue: false + property string appClass: "" // Bored, yawning, lying down at 20, 40 and 70% of the wscreensavers idle // timeout, so she has lain down a while before the saver covers her (1, @@ -120,7 +149,9 @@ PanelWindow { root.wakeAt = t; root.say("wake"); } else if (root.idle === 1 && root.wasIdle === 0) { - if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue(); + root.pickGroove(); + if (Player.playing) root.say("groove"); + else if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue(); else root.say("bored"); } root.wasIdle = root.idle; @@ -161,9 +192,19 @@ PanelWindow { // the keybind reminder. Map again, after it. root.remapping = true; remap.restart(); - } else if (["fullscreen", "activewindowv2", "workspacev2"].includes(event.name)) { + } else if (event.name === "activewindow") { + // "class,title". A remark now and then when focus moves to a + // different program, under the usual 30 s line cooldown. + // ponytail: one line per class, no frames of her own; add + // per-app frames or title matching (a site in firefox) if + // lines alone feel thin. + const cls = event.data.split(",")[0].trim().toLowerCase(); + if (cls !== "" && cls !== root.appClass && Math.random() < 1 / 3) root.say("app:" + cls); + root.appClass = cls; + } else if (["fullscreen", "activewindowv2", "workspacev2", "closewindow"].includes(event.name)) { Hyprland.refreshToplevels(); fsSettle.restart(); + admireSettle.restart(); } } } @@ -176,6 +217,68 @@ PanelWindow { } } + // Sony headphones connecting, by the island's rule (island/StatusPill.qml): + // recognised by their model prefix, since BlueZ gives no class that says + // "headphones". A connection makes her sing for Mood.T.singHold. Bluetooth + // reads empty for about 2 s after launch, so a shell start with them + // already on is not a connection: btReady waits past that. + readonly property bool phonesConnected: (Bluetooth.devices?.values ?? []) + .some(d => d.connected && /^(WH|WF)-|^LinkBuds/.test(d.deviceName)) + property bool btReady: false + // shortcut: a genuine connection in the first 4 s after a shell start is + // missed along with the startup populate; tighten if that bites. + Timer { interval: 4000; running: true; onTriggered: root.btReady = true } + onPhonesConnectedChanged: { + if (!root.phonesConnected || !root.btReady) return; + root.singAt = Date.now(); + root.now = root.singAt; + root.say("headphones"); + } + + // What is playing, from the sound module's player (it already drops the + // playerctld and browser-integration duplicates). A new artist+title + // while playing is a track start or change: she DJs for Mood.T.djHold + // and says the artist's line if she knows them. Paused reads "", and + // resuming the same track matches lastTrack, so it does not DJ again. + readonly property string track: Player.playing ? Player.artist + "\n" + Player.title : "" + property string lastTrack: "" + onTrackChanged: { + if (root.track === "" || root.track === root.lastTrack) return; + root.lastTrack = root.track; + root.djAt = Date.now(); + root.now = root.djAt; + root.say("artist:" + Player.artist); + } + // Music starting while she is already bored needs a groove too. + readonly property bool music: Player.playing + onMusicChanged: if (root.music && root.idle === 1) root.pickGroove() + function pickGroove() { + const g = ["whistle", "headbang", "airguitar"]; + root.groove = g[Math.floor(Math.random() * g.length)]; + } + + // A listed wallpaper on an empty workspace: she turns to it and blushes. + // Checked after the events above (switching to an empty workspace, + // closing its last window) and whenever she stops being away: shown, + // back from a remap, a hop, a dodge or fullscreen. Toplevels settle a + // moment after the event, as fullscreen does. + onAwayChanged: if (!root.away) admireSettle.restart() + Timer { + id: admireSettle + interval: 100 + onTriggered: { + const t = Date.now(); + if (root.away || root.here !== root.focused || !Mood.admireAllowed(root.admireAt, t)) return; + if ((Hyprland.focusedWorkspace?.toplevels.values.length ?? 1) !== 0) return; + if (!Admired.listed(Admired.on(root.here))) return; + root.admireAt = t; + root.now = t; + admireLine.restart(); + } + } + // The line comes with the blush, not the turn. + Timer { id: admireLine; interval: Mood.T.admireLook; onTriggered: root.say("wallpaper") } + // Hyprland stacks a later surface above an earlier one on the same layer // and 0.56.2 has no layer rule to order them, so the assistant canvas's // full-screen dim covered her and her think and wave were never seen. @@ -204,30 +307,34 @@ PanelWindow { // --- state ---------------------------------------------------------- readonly property var mood: Mood.pick({ + leaving: root.leaving, now: root.now, destructAt: root.destructAt, dodging: root.dodging, shoved: root.shoved, shovedAt: root.shovedAt, recording: root.asst.recording, busy: root.asst.busy, repliedAt: root.repliedAt, - clicks: root.clicks, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt, + clicks: root.clicks, admireAt: root.admireAt, mockAt: root.mockAt, typingAt: root.typingAt, wakeAt: root.wakeAt, + singAt: root.singAt, djAt: root.djAt, music: root.music, groove: root.groove, monologue: root.monologue, idle: root.idle, }) - readonly property string outfit: Mood.outfit(root.destructAt, root.now) + readonly property string outfit: Mood.outfit(root.destructAt, root.now, Prefs.companionSkirtless * 1000) // Anything above the monologue cuts it short. onMoodChanged: if (root.monologue && root.mood.state !== "muse") root.stopMonologue() // Hidden: no sprite, no bubble. Away adds the dodge, which hides the - // sprite but lets its line show. - readonly property bool hidden: !root.present || root.fullscreen === 2 || root.hopping + // sprite but lets its line show. While waving goodbye she is still on + // screen, so a hidden present does not hide her until the wave is done. + readonly property bool hidden: (!root.present && !root.leaving) || root.fullscreen === 2 || root.hopping readonly property bool away: root.hidden || root.dodging || root.remapping // --- speech --------------------------------------------------------- function say(kind, force) { const t = Date.now(); - if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return; - const line = Lines.pick(kind, Math.random()); - if (line === "") return; + if (root.hidden || (!force && !Mood.canSpeak(root.lastLineAt, t))) return false; + const line = Lines.pick(kind, Math.random(), Prefs.companionApps, Prefs.companionArtists); + if (line === "") return false; root.lastLineAt = t; bubble.show(line, 3000); + return true; } // The opening monologue: about 15 characters a second, one sentence @@ -319,6 +426,35 @@ PanelWindow { } } + // The goodbye: she waves, then leaves. With a line she holds long enough + // to read it. The hand sways side to side while it lasts. + Timer { id: goodbyeTimer; onTriggered: root.leaving = false } + Timer { + id: waveFlip + interval: 250 + repeat: true + running: root.leaving + onTriggered: root.waveIndex = (root.waveIndex + 1) % root.waveCycle.length + } + + // Frames Mood names that are really a loop: the frame names its cycle + // and how fast it turns. Everything else shows as named. + readonly property var cycles: ({ + dj: { frames: ["dj", "dj-scratch"], ms: 450 }, + headbang: { frames: ["headbang-up", "headbang-down"], ms: 220 }, + }) + property int cycleIndex: 0 + readonly property var cycle: root.cycles[root.mood.frame] ?? null + onCycleChanged: root.cycleIndex = 0 + Timer { + interval: root.cycle?.ms ?? 1000 + repeat: true + running: root.cycle !== null && !root.away + onTriggered: root.cycleIndex = (root.cycleIndex + 1) % root.cycle.frames.length + } + readonly property string frame: root.leaving ? root.waveCycle[root.waveIndex] + : root.cycle ? root.cycle.frames[root.cycleIndex % root.cycle.frames.length] : root.mood.frame + // qs -p <desktop> ipc call companion toggle -> SUPER+\ IpcHandler { target: "companion" @@ -354,9 +490,10 @@ PanelWindow { id: sprite x: seat.x y: seat.y - source: `file://${root.art}/${root.outfit}/${root.mood.frame}.png` + source: `file://${root.art}/${root.outfit}/${root.frame}.png` fallback: `file://${root.art}/${root.outfit}/base.png` out: root.away + trembling: root.mood.state === "admire" && root.mood.frame === "blush3" onSettled: out => root.settled(out) } diff --git a/desktop/companion/Figure.qml b/desktop/companion/Figure.qml index 0b3b671..3401168 100644 --- a/desktop/companion/Figure.qml +++ b/desktop/companion/Figure.qml @@ -13,8 +13,8 @@ import QtQuick import ".." // One frame of the companion, drawn at half its pixel size and standing on -// its bottom edge: a crossfade between frames, a slow bob, the drop out of -// view and back, and the self-destruct flash. Which frame is Mood.js's +// its bottom edge: a crossfade between frames, a slow bob, a tremble, the +// drop out of view and back, and the self-destruct flash. Which frame is Mood.js's // business; this only draws it. Item { id: s @@ -65,6 +65,18 @@ Item { dropAnim.easing.type = s.out ? Easing.InCubic : Easing.OutBack; dropAnim.start(); } + // A quick side to side shiver, for the wallpaper blush. Stopping can + // leave it mid-swing, so it is put back to rest. + property bool trembling: false + property real shake: 0 + onTremblingChanged: if (!s.trembling) s.shake = 0 + SequentialAnimation on shake { + running: s.trembling && !s.out + loops: Animation.Infinite + NumberAnimation { to: 1.5; duration: 40 } + NumberAnimation { to: -1.5; duration: 80 } + NumberAnimation { to: 0; duration: 40 } + } SequentialAnimation on bob { running: !s.out loops: Animation.Infinite @@ -75,7 +87,7 @@ Item { Item { anchors.fill: parent opacity: 1 - s.drop / (s.height + 24) - transform: Translate { y: s.drop - s.bob } + transform: Translate { x: s.shake; y: s.drop - s.bob } Image { id: a diff --git a/desktop/companion/Lines.js b/desktop/companion/Lines.js index 6f387aa..77e7a83 100644 --- a/desktop/companion/Lines.js +++ b/desktop/companion/Lines.js @@ -17,18 +17,54 @@ var lines = { shown: ["Glory to mankind.", "2B, reporting.", "...Back on duty."], + bye: ["2B, signing off.", "Rest while you can.", "Duty complete."], bored: ["...Operator, I'm bored.", "Nothing to report.", "Awaiting orders."], wake: ["I'm awake.", "Systems online."], mock: ["Typo again?", "Delete, delete, delete.", "Were you aiming for that key?"], shoved: ["Hey.", "Rude.", "I was standing here."], ready: ["Answer's ready.", "Your reply came in."], + headphones: ["...Good song.", "Music. Finally.", "I'll allow this one.", "Louder."], + groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."], click: ["...Yes?", "Can I help you?"], blush: ["S-stop that.", "...Operator."], + wallpaper: ["...!", "W-what is this?", "I wasn't staring."], dodge: ["...Fine.", "I'll move."], destruct: ["Self-destruct sequence initiated."], aftermath: ["...That was unpleasant."], }; +// The lines for a program she recognizes, by lowercased window class: one is +// said at random when focus moves to it. The settings window's Companion +// phrases page edits them into Prefs.companionApps, which then replaces this +// whole set; a class with no entry says nothing. +var apps = { + firefox: ["Researching again?", "Don't open too many tabs.", "The Bunker's network is faster."], + kitty: ["A terminal. Serious work.", "Careful with that rm.", "I'll watch your commands."], + discord: ["Talking to the Resistance?", "Don't tell them where we are."], + signal: ["Encrypted. Good."], + steam: ["Training simulation?", "Don't play all night."], + gimp: ["Making art?"], + "com.obsproject.studio": ["Are we recording?", "I'll stay out of frame."], + "md.obsidian.obsidian": ["Writing a report?", "Taking notes. Good habit."], + sublime_text: ["Writing code?", "Mind the typos."], + qtmaildir: ["Any orders from Command?", "Mail. Anything urgent?"], + "virt-manager": ["More machines?", "Androids running androids."], + audacious: ["Music. I like this."], + llamachat: ["Talking to Pod?"], + qtpass: ["I'm not looking."], +}; + +// The lines for an artist she knows, keyed by a lowercased name matched as +// a substring of the reported artist, so "daft punk" also catches +// collaborations. The Companion artists page edits them into +// Prefs.companionArtists, which then replaces this whole set. +var artists = { + "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."], + "evanescence": ["Wake me up inside. ...Operator, don't laugh."], + "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."], + "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."], +}; + // The game's opening, one sentence per bubble. var monologue = [ "Everything that lives is designed to end.", @@ -38,8 +74,73 @@ var monologue = [ "I often think about the god who blessed us with this cryptic puzzle...and wonder if we'll ever get the chance to kill him.", ]; -// A line for kind; roll is Math.random(). "" for a kind with no lines. -function pick(kind, roll) { - const l = lines[kind] || []; - return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : ""; +// A set in effect: saved when it is a plain object, the defaults (apps +// unless given) otherwise. A malformed saved value must not leave her mute. +function appMap(saved, defaults) { + return saved && typeof saved === "object" && !Array.isArray(saved) ? saved : (defaults || apps); +} + +// A set as an ordered list of {cls, lines}, for the settings pages; cls is +// the key, a window class or an artist. +function appList(saved, defaults) { + const m = appMap(saved, defaults); + const keys = Object.keys(m); + const out = []; + for (let i = 0; i < keys.length; i++) + out.push({ cls: keys[i], lines: (m[keys[i]] || []).slice() }); + return out; +} + +function nonEmpty(all) { + const l = []; + for (let i = 0; i < (all || []).length; i++) + if (typeof all[i] === "string" && all[i].trim() !== "") l.push(all[i]); + return l; +} + +function at(l, roll) { return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : ""; } + +// A line for an app class, empty strings skipped; "" when the class has none. +function pickApp(cls, saved, roll) { return at(nonEmpty(appMap(saved)[cls]), roll); } + +// A line for the playing artist: the first key that is a case-insensitive +// substring of it and has a non-empty line. ponytail: substring, so a +// short key like "air" also matches "Airbourne"; whole words if that bites. +function pickArtist(artist, saved, roll) { + const a = String(artist || "").toLowerCase(); + if (a === "") return ""; + const m = appMap(saved, artists); + const keys = Object.keys(m); + for (let i = 0; i < keys.length; i++) { + const l = nonEmpty(m[keys[i]]); + if (keys[i] !== "" && l.length && a.indexOf(keys[i].toLowerCase()) !== -1) return at(l, roll); + } + return ""; +} + +// A line for a reaction kind, or, with an "app:" or "artist:" prefix, for +// that class or artist. +function pick(kind, roll, saved, savedArtists) { + if (kind.slice(0, 4) === "app:") return pickApp(kind.slice(4), saved, roll); + if (kind.slice(0, 7) === "artist:") return pickArtist(kind.slice(7), savedArtists, roll); + return at(lines[kind] || [], roll); +} + +// The page's list back to a map for Prefs: empty classes and phrases dropped, +// the class lowercased (Hyprland reports it that way), a repeated class +// keeping its last entry. +function toMap(list) { + const m = {}; + for (let i = 0; i < list.length; i++) { + const cls = String(list[i].cls || "").trim().toLowerCase(); + if (cls === "") continue; + const src = list[i].lines || []; + const ls = []; + for (let j = 0; j < src.length; j++) { + const s = String(src[j] || "").trim(); + if (s !== "") ls.push(s); + } + if (ls.length) m[cls] = ls; + } + return m; } diff --git a/desktop/companion/ListFile.js b/desktop/companion/ListFile.js new file mode 100644 index 0000000..d074c80 --- /dev/null +++ b/desktop/companion/ListFile.js @@ -0,0 +1,44 @@ +// 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 wallpapers the companion blushes at, as a plain text file: one +// absolute path per line, blank lines and lines starting with # skipped. +// Pure, so test_mood.js drives it; Admired.qml reads and writes the file. + +function parse(text) { + const out = []; + for (const l of text.split("\n")) { + const p = l.trim(); + if (p !== "" && p[0] !== "#") out.push(p); + } + return out; +} + +// text with path's line removed if it is listed, else appended. Comments +// and every other line stay as they are. +function toggle(text, path) { + const lines = text === "" ? [] : text.replace(/\n$/, "").split("\n"); + const kept = lines.filter(l => l.trim() !== path); + if (kept.length === lines.length) kept.push(path); + return kept.length ? kept.join("\n") + "\n" : ""; +} + +// wallp.conf's OUTPUT_H and OUTPUT_V, as { connector: "H" | "V" }. An exec +// loop, because QML's engine has no String.matchAll. +function outputs(conf) { + const out = {}; + const re = /^\s*OUTPUT_([HV])\s*=\s*(\S+)/gm; + let m; + while ((m = re.exec(conf)) !== null) out[m[2]] = m[1]; + return out; +} diff --git a/desktop/companion/Mood.js b/desktop/companion/Mood.js index a3bc8cb..b59c1ab 100644 --- a/desktop/companion/Mood.js +++ b/desktop/companion/Mood.js @@ -23,12 +23,18 @@ var T = { mockCooldown: 20000, typingHold: 2000, waveHold: 3000, + singHold: 4000, // her singing reaction to the headphones connecting + djHold: 6000, // behind the decks when a track starts or changes wakeHold: 1500, // the stretch after idling lineCooldown: 30000, monologueCooldown: 3600000, destruct: 1000, // the glow and the flash; the sprite is hidden under it scorched: 3000, - skirtless: 60000, + goodbye: 2000, // waving goodbye with a line, enough to read it + goodbyeSilent: 1000, // waving goodbye with nothing to read + admireLook: 1500, // turned to the wallpaper, before the blush + admireBlush: 3000, + admireCooldown: 180000, }; function st(state, frame) { return { state: state, frame: frame }; } @@ -51,6 +57,9 @@ function clickFrame(count) { // The spec's priority table, top to bottom: the first that applies wins. // idle is 0 awake, 1 bored, 2 yawning, 3 lying down. function pick(s) { + // The user hid her: the goodbye outranks whatever she was doing. The + // frame here is a placeholder; Companion.qml cycles the wave frames. + if (s.leaving) return st("bye", "wave"); const since = s.destructAt ? s.now - s.destructAt : Infinity; if (since < T.destruct) return st("destruct", "base"); if (since < T.destruct + T.scorched) return st("scorched", "scorched"); @@ -60,8 +69,12 @@ function pick(s) { if (s.recording) return st("listen", "listen"); if (s.busy) return st("think", "think"); if (s.now - s.repliedAt < T.waveHold) return st("wave", "wave"); + if (s.now - s.singAt < T.singHold) return st("sing", "sing"); + if (s.now - s.djAt < T.djHold) return st("dj", "dj"); if (s.clicks.count > 0 && s.now - s.clicks.at < T.clickHold) return st("clicks", clickFrame(s.clicks.count)); + if (s.now - s.admireAt < T.admireLook + T.admireBlush) + return st("admire", s.now - s.admireAt < T.admireLook ? "watch" : "blush3"); if (s.now - s.mockAt < T.mockHold) return st("mock", s.now - s.mockAt < T.mockHold / 2 ? "point" : "smirk"); if (s.now - s.typingAt < T.typingHold) return st("watch", "watch"); @@ -69,21 +82,27 @@ function pick(s) { if (s.monologue) return st("muse", "muse"); if (s.idle >= 3) return st("lying", "lying"); if (s.idle === 2) return st("yawn", "yawn"); - if (s.idle === 1) return st("bored", "sleepy"); + // Music turns boredom into a groove picked by Companion.qml; the + // deeper idle stages still win, so music does not keep her awake. + if (s.idle === 1) return s.music ? st("groove", s.groove || "whistle") : st("bored", "sleepy"); return st("base", "base"); } // The self-destruct takes the skirt off once the flash is over, for the -// scorched moment and the skirtless minute after it. Showing her again -// sets destructAt back to 0, which restores it early. -function outfit(destructAt, now) { +// scorched moment and skirtless ms after it (Prefs.companionSkirtless), +// or with 0 until she is shown again, which sets destructAt back to 0 and +// also restores it early. +function outfit(destructAt, now, skirtless) { if (!destructAt) return "dress"; const since = now - destructAt; - return since >= T.destruct && since < T.destruct + T.scorched + T.skirtless ? "leotard" : "dress"; + if (since < T.destruct) return "dress"; + return skirtless === 0 || since < T.destruct + T.scorched + skirtless ? "leotard" : "dress"; } function mockAllowed(lastMockAt, now) { return now - lastMockAt >= T.mockCooldown; } +function admireAllowed(lastAt, now) { return now - lastAt >= T.admireCooldown; } + function canSpeak(lastLineAt, now) { return now - lastLineAt >= T.lineCooldown; } // Whether the opening monologue plays: a chance per trigger, and never diff --git a/desktop/companion/README.md b/desktop/companion/README.md index 4582b64..4fbe887 100644 --- a/desktop/companion/README.md +++ b/desktop/companion/README.md @@ -11,7 +11,9 @@ The design, with what was measured on the way, is | `Companion.qml` | the window, inputs, timers, speech, IPC (`companion toggle/show/hide`) | | `Figure.qml` | draws a frame: crossfade, bob, drop out and back, explosion | | `Mood.js` | which state wins, from a snapshot; all the timings are its `T` | -| `Lines.js` | her lines and the opening monologue | +| `Lines.js` | her reaction, per-app and per-artist lines, and the opening monologue | +| `Admired.qml` | the wallpaper list she blushes at, and what wallp has on each monitor | +| `ListFile.js` | that list as plain text: parse and toggle | | `prep-frames` | generated images to display frames | | `gen-frames`, `frames.txt` | the generated images, through sd-server | @@ -19,9 +21,16 @@ The design, with what was measured on the way, is it wins over the directory's own file (see AGENTS.md). Whether she is shown is `companion` in `Prefs.qml`, so it survives a -restart: SUPER+\ and the switch on Settings > Desktop > Companion +restart: SUPER+\ and the switch on Settings > Companion (`desktop/modules/companion/`) both set it. +A hide by SUPER+\ or that switch plays a goodbye first: she waves (`wave`, +`wave-l` and `wave-r` cycling) and then drops out. One hide in four she also +says a `bye` line, under the usual 30 s line cooldown; with a line she holds +`Mood.T.goodbye` (2 s, enough to read it), silently only +`Mood.T.goodbyeSilent` (0.5 s). Fullscreen, the lock screen and the monitor +hop keep their own exits and do not wave. + ## Art Not in the repo: the character is Square Enix's and this repo is GPLv2. @@ -51,6 +60,34 @@ frame falls back to its outfit's `base`. stacks a later surface above an earlier one on a layer, and 0.56.2 has no layer rule to order them (no `order` key in the binary; an unknown key raises no config error). +- The program in front of her is Hyprland's `activewindow` event, + `class,title`. It also fires on every title change, so she reacts + only when the class changes, a third of the time, under the 30 s line + cooldown. Lines are by lowercased class in `Lines.js`; a class with + none says nothing. Settings > Companion > Companion phrases edits them + into `Prefs.companionApps`, which then replaces the defaults. +- A listed wallpaper (Settings > Companion for the file, the + heart in the wallpaper picker for entries) on an empty workspace: she + turns to it with `watch` and blushes with `blush3`, at most every 3 + minutes. Checked on `workspacev2` and `closewindow`, and whenever she + stops being away. Her monitor's wallpaper comes from wallp's + `wallp.conf` outputs and `wall_h`/`wall_v`. +- Sony headphones connecting are the island's rule (`island/StatusPill.qml`: + `WH-`/`WF-`/`LinkBuds` by name, since BlueZ has no headphones class): she + sings once for `Mood.T.singHold`, 4 s, and says a line if the usual 30 s + cooldown allows it. A shell start with them already on is not a connection: + Bluetooth reads empty for ~2 s, then populates. +- What is playing is `modules/sound/Player.qml`, which already drops the + playerctld and browser-integration duplicates. A new artist+title while + playing makes her DJ for `Mood.T.djHold`, 6 s (`dj` and `dj-scratch` + cycling), and say the artist's line if one matches: lowercased keys in + `Lines.artists`, matched as a substring, edited on Settings > Companion + artists into `Prefs.companionArtists`. Pause and resume of the same + track is not a change; a shell start with music on DJs once. While + music plays, the first idle stage is a groove instead of bored: + `whistle`, `headbang` (`headbang-up`/`-down` cycling) or `airguitar`, + picked at random, with a `groove` line; yawning and lying down are + unchanged. - She only feels the pointer while it is over her: Quickshell has no global pointer and Hyprland sends no motion events. Hence the 0.6 s dodge rather than moving away as the pointer approaches. @@ -70,3 +107,4 @@ frame falls back to its outfit's `base`. ## Check node desktop/companion/test_mood.js + node desktop/companion/test_lines.js diff --git a/desktop/companion/frames.txt b/desktop/companion/frames.txt index 0019db1..087089d 100644 --- a/desktop/companion/frames.txt +++ b/desktop/companion/frames.txt @@ -34,6 +34,9 @@ EYES = Her black blindfold stays on as one solid band of cloth fully covering bo # After the self-destruct the blindfold is gone with the skirt: the whole # leotard set shows her eyes. LEOTARD = Her skirt is gone, revealing a plain white leotard underneath, and her blindfold is gone too: both eyes uncovered and clearly visible, pale blue-grey, matching her expression. Everything else stays exactly the same: pose, expression, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips, the small mole under her mouth, chibi proportions, art style and white background. +# Singing along to whatever is playing: headphones on, eyes shut. The +# blindfold is gone, but BARE does not fit because it says the eyes show. +SING = Her black blindfold is gone: nothing over her eyes, no blindfold, no mask; her black hairband stays. Both her eyes are closed, softly shut. Her mouth is open wide as if she is singing loudly. She wears a pair of black over-ear headphones over her head and ears. A couple of small black musical notes float in the air beside her head. # --- expressions dress/smile <- dress/base : Change only her expression to a small warm closed-mouth smile; ${POSE} ${EYES} ${KEEP} @@ -46,6 +49,10 @@ dress/sleepy <- dress/base : Change only her expression to sleepy and drowsy, h # --- poses dress/wave <- dress/base : Raise only the hand on the left side of the image beside her head, waving, with a small smile; her other arm stays at her side. ${KEEP} +# The goodbye wave sways side to side. Edited from wave, the centre of the +# sway, so klein keeps the arm up and moves only the hand. +dress/wave-l <- dress/wave : Change only the hand she holds up: bend it at the wrist toward the left side of the image, fingers pointing left, as if in the middle of a goodbye wave; keep the arm raised beside her head, her small smile, and everything else exactly the same. ${KEEP} +dress/wave-r <- dress/wave : Change only the hand she holds up: bend it at the wrist toward the right side of the image, fingers pointing right, as if in the middle of a goodbye wave; keep the arm raised beside her head, her small smile, and everything else exactly the same. ${KEEP} # Shown while typing: she faces into the screen, away from the viewer, as if # reading over the user's shoulder. A profile read as posing, not watching. dress/watch <- dress/base : Turn her completely around into a back view: we see her from behind, the back of her head with her white bob, crossed by her black hairband and the small black cloth knot of her black blindfold (black, not white, no bow), her back, the back of her dress and the backs of her legs, as if she is facing a screen and watching it; arms relaxed at her sides. Same character, black gloves, puffy black juliet sleeves, black dress with its skirt, black thighhigh boots, black hairband, chibi proportions, art style and white background. @@ -67,6 +74,18 @@ dress/stretch <- dress/base : She stretches both arms up above her head, waking # and face were right: refs/shoved-7.png, made from dress/base with # --seed 7 by the earlier standing prompt (see generated.log), kept in refs/. dress/shoved <- refs/shoved-7 : Keep her face, her scared expression, her uncovered eyes and both raised hands exactly as they are. Change her body and legs: she is in the air, lifted completely off the ground, knocked flying toward the left side of the image by a strong blast from the right; both feet off the ground, legs trailing behind her toward the right side, her body tilted, her hair and skirt blown toward the left. No ground, no blast effect drawn. ${KEEP_BARE} +# Headphones connecting: a few seconds of her singing along. +dress/sing <- dress/base : ${SING} ${POSE} ${KEEP_BARE} +# Music: behind the decks on a track start or change, and the grooves +# that replace boredom while music plays. +DECK = A small black DJ booth stands in front of her: two turntables and a mixer on top at her waist height, and a solid black front panel reaching down to the ground, so her legs and boots are hidden behind it and only her upper body shows above it. No table legs. +dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. Both her eyes are closed, softly shut, enjoying the music. She wears a pair of black over-ear headphones over her head and ears. ${KEEP_BARE} +dress/dj-scratch <- dress/dj : Change only her arms: the hand on the left side of the image scratches a record, the other holds one headphone cup to her ear. Everything else exactly the same. ${KEEP} +dress/whistle <- dress/base : Change only her expression: lips puckered, whistling, a couple of small black musical notes floating beside her mouth. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. Both her eyes are closed, softly shut, relaxed. ${POSE} ${KEEP_BARE} +dress/headbang-up <- dress/base : She rocks out: her upper body leans far back, her face tipped up to the sky, mouth wide open shouting, eyes squeezed shut, one fist raised high above her head making the horns hand sign with index and little finger up, the other fist clenched at her side, her hair flying back. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. ${KEEP_BARE} +# headbang-down: the kept result is --seed 7. +dress/headbang-down <- dress/base : She headbangs hard: seen from the front, her whole upper body is bent forward at the waist by about 45 degrees, her head thrown down so the top of her head faces the viewer, her white hair flung forward and hanging down over her face, both fists clenched in front of her chest, knees slightly bent. Her face is hidden by her hair. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. ${KEEP_BARE} +dress/airguitar <- dress/base : She plays an electric guitar with a glowing neon blue body and a black neck, held across her hips: one hand strumming at the body, the other fretting the neck to the side, knees bent, eyes shut, mouth open in a big grin. Her black blindfold is gone: nothing over her eyes, no blindfold, no mask, no band; her black hairband stays. ${KEEP_BARE} # --- the leotard set: the same frames, after the self-destruct leotard/base <- dress/base : ${LEOTARD} @@ -80,6 +99,8 @@ leotard/blush2 <- dress/blush2 : ${LEOTARD} leotard/blush3 <- dress/blush3 : ${LEOTARD} leotard/sleepy <- dress/sleepy : ${LEOTARD} leotard/wave <- dress/wave : ${LEOTARD} +leotard/wave-l <- dress/wave-l : ${LEOTARD} +leotard/wave-r <- dress/wave-r : ${LEOTARD} # From dress/watch, klein kept the blindfold's band at the back of her head # through three prompts, by name and by position; from leotard/base, which # has no blindfold, the back view is the usual pose edit. @@ -96,4 +117,11 @@ leotard/think <- dress/think : ${LEOTARD} leotard/muse <- dress/muse : ${LEOTARD} leotard/stretch <- dress/stretch : ${LEOTARD} leotard/shoved <- dress/shoved : ${LEOTARD} +leotard/sing <- dress/sing : Her skirt is gone, revealing a plain white leotard underneath. Change nothing else: she keeps her closed eyes, her black over-ear headphones, the musical notes beside her head, her pose, black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background. leotard/scorched <- leotard/base : Right after an explosion: her clothes lightly scorched and smudged with soot, a few wisps of smoke rising from her, hair slightly messy, a dazed flat expression; standing, arms at her sides. Her eyes stay uncovered, no blindfold. Her top stays black: the black bodice with its chest cutout and the puffy black juliet sleeves, exactly as in the image; the plain white leotard shows only below her waist, where the skirt was. She keeps her black gloves, black thighhigh boots, black hairband, lips and the small mole under her mouth. Same character, chibi proportions, art style and white background. +leotard/dj <- dress/dj : ${LEOTARD} +leotard/dj-scratch <- dress/dj-scratch : ${LEOTARD} +leotard/whistle <- dress/whistle : ${LEOTARD} +leotard/headbang-up <- dress/headbang-up : ${LEOTARD} +leotard/headbang-down <- dress/headbang-down : Her skirt is gone, revealing a plain white leotard underneath. Change nothing else: her head stays lowered with her white hair hanging down over her whole face, hiding it completely, no eyes showing, no blindfold; her fists stay clenched in front of her chest; she keeps her black gloves, puffy black juliet sleeves, black bodice, black thighhigh boots, black hairband and pose. Same character, chibi proportions, art style and white background. +leotard/airguitar <- dress/airguitar : ${LEOTARD} diff --git a/desktop/companion/test_lines.js b/desktop/companion/test_lines.js new file mode 100644 index 0000000..72addf2 --- /dev/null +++ b/desktop/companion/test_lines.js @@ -0,0 +1,79 @@ +// 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/companion/test_lines.js +// Lines.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 + "/Lines.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const L = new Function(src + + "\nreturn { lines, apps, artists, monologue, appMap, appList, pickApp, pickArtist, pick, toMap };")(); + +// The defaults are what she says until a set is saved. +assert.strictEqual(L.pickApp("firefox", null, 0), L.apps.firefox[0]); +assert.strictEqual(L.pickApp("firefox", null, 0.99), L.apps.firefox[L.apps.firefox.length - 1]); +assert.strictEqual(L.pickApp("nope", null, 0), ""); +assert.strictEqual(L.appMap(null), L.apps); +// A hand edit with a typo reads as the defaults, not as silence. +assert.strictEqual(L.appMap("typo"), L.apps); +assert.strictEqual(L.appMap(["not", "a", "map"]), L.apps); + +// A saved set replaces the defaults, class by class. +assert.strictEqual(L.pickApp("firefox", { firefox: ["hi"] }, 0), "hi"); +assert.strictEqual(L.pickApp("kitty", { firefox: ["hi"] }, 0), ""); +assert.strictEqual(L.pick("app:firefox", 0, { firefox: ["custom"] }), "custom"); +assert.strictEqual(L.pick("app:firefox", 0), L.apps.firefox[0]); + +// Empty phrases are skipped when picking; a class with only empty ones says +// nothing. +assert.strictEqual(L.pickApp("firefox", { firefox: ["", " ", "only"] }, 0), "only"); +assert.strictEqual(L.pickApp("firefox", { firefox: ["", " "] }, 0), ""); + +// Reactions still come from lines. +assert.strictEqual(L.pick("shown", 0), L.lines.shown[0]); +assert.strictEqual(L.pick("nope", 0), ""); + +// The saved set as an ordered list, for the settings page. +const list = L.appList(null); +assert.strictEqual(list.length, Object.keys(L.apps).length); +assert.deepStrictEqual(list[0], { cls: "firefox", lines: L.apps.firefox.slice() }); +assert.deepStrictEqual(L.appList({ kitty: ["a"] }), [{ cls: "kitty", lines: ["a"] }]); + +// Back to a map: trim, lowercase the class, drop empty classes and empty +// phrases, and a repeated class keeps its last entry. +assert.deepStrictEqual(L.toMap([ + { cls: " Firefox ", lines: [" one ", "", " ", "two"] }, + { cls: "", lines: ["dropped"] }, + { cls: "kitty", lines: ["", " "] }, + { cls: "firefox", lines: ["last"] }, +]), { firefox: ["last"] }); + +// Artists: a case-insensitive substring of what MPRIS reports, first +// matching key wins, empty phrases skipped. +assert.strictEqual(L.pickArtist("Linkin Park", null, 0), L.artists["linkin park"][0]); +assert.strictEqual(L.pickArtist("LINKIN PARK, Jay-Z", null, 0), L.artists["linkin park"][0]); +assert.strictEqual(L.pickArtist("Nobody", null, 0), ""); +assert.strictEqual(L.pickArtist("", null, 0), ""); +assert.strictEqual(L.pickArtist("Air, Beck", { air: ["a"], beck: ["b"] }, 0), "a"); +assert.strictEqual(L.pickArtist("Airbourne", { air: ["", "a"] }, 0), "a"); +assert.strictEqual(L.pick("artist:Eminem", 0, null, null), L.artists.eminem[0]); +assert.strictEqual(L.pick("artist:Eminem", 0, null, { eminem: ["mine"] }), "mine"); +assert.strictEqual(L.pick("groove", 0), L.lines.groove[0]); + +// The same list helpers serve the artist set. +assert.strictEqual(L.appMap(null, L.artists), L.artists); +assert.strictEqual(L.appList(null, L.artists).length, Object.keys(L.artists).length); +assert.deepStrictEqual(L.toMap(L.appList(null, L.artists)), L.artists); +console.log("ok"); diff --git a/desktop/companion/test_mood.js b/desktop/companion/test_mood.js index 90ebdd6..3439f7c 100644 --- a/desktop/companion/test_mood.js +++ b/desktop/companion/test_mood.js @@ -19,13 +19,17 @@ const assert = require("assert"); const src = fs.readFileSync(__dirname + "/Mood.js", "utf8") .replace(/^\.pragma library$/m, ""); const M = new Function(src + - "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue };")(); + "\nreturn { T, click, clickFrame, pick, outfit, mockAllowed, canSpeak, monologue, admireAllowed };")(); +const lsrc = fs.readFileSync(__dirname + "/ListFile.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const L = new Function(lsrc + "\nreturn { parse, toggle, outputs };")(); const NOW = 10000000; // A snapshot with nothing going on, overridden per case. const calm = over => Object.assign({ - now: NOW, destructAt: 0, dodging: false, shoved: false, shovedAt: 0, recording: false, busy: false, - repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, + leaving: false, now: NOW, destructAt: 0, dodging: false, shoved: false, shovedAt: 0, recording: false, busy: false, + repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0, singAt: 0, + djAt: 0, music: false, groove: "", monologue: false, idle: 0, }, over); const state = over => M.pick(calm(over)).state; @@ -37,6 +41,7 @@ assert.deepStrictEqual(M.pick(calm({})), { state: "base", frame: "base" }); // The priority table, top to bottom: with every trigger from a row down // active at once, that row wins. Merged so a higher row's values win. const ladder = [ + ["bye", { leaving: true }], ["destruct", { destructAt: NOW - 10 }], ["scorched", { destructAt: NOW - M.T.destruct - 10 }], ["dodge", { dodging: true }], @@ -44,7 +49,10 @@ const ladder = [ ["listen", { recording: true }], ["think", { busy: true }], ["wave", { repliedAt: NOW - 10 }], + ["sing", { singAt: NOW - 10 }], + ["dj", { djAt: NOW - 10 }], ["clicks", { clicks: { count: 2, at: NOW - 10 } }], + ["admire", { admireAt: NOW - 10 }], ["mock", { mockAt: NOW - 10 }], ["watch", { typingAt: NOW - 10 }], ["stretch", { wakeAt: NOW - 10 }], @@ -56,6 +64,11 @@ for (let i = 0; i < ladder.length; i++) { assert.strictEqual(state(all), ladder[i][0], "everything from " + ladder[i][0] + " down"); } +// A hide: she waves goodbye, above everything else, whatever she was doing. +assert.deepStrictEqual( + M.pick(calm({ leaving: true, idle: 3, clicks: { count: 3, at: NOW - 10 } })), + { state: "bye", frame: "wave" }); + // Shoved: knocked off balance for a moment, then mad beside the balloon // for as long as it stays. assert.deepStrictEqual(M.pick(calm({ shoved: true, shovedAt: NOW - 10 })), { state: "shoved", frame: "shoved" }); @@ -67,8 +80,21 @@ assert.deepStrictEqual(M.pick(calm({ idle: 1 })), { state: "bored", frame: "slee assert.deepStrictEqual(M.pick(calm({ idle: 2 })), { state: "yawn", frame: "yawn" }); assert.deepStrictEqual(M.pick(calm({ idle: 3 })), { state: "lying", frame: "lying" }); +// Music while bored: she grooves with the picked frame instead, whistling +// when nothing was picked; yawning and lying down are unchanged. +assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true, groove: "headbang" })), { state: "groove", frame: "headbang" }); +assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true })), { state: "groove", frame: "whistle" }); +assert.deepStrictEqual(M.pick(calm({ idle: 1, groove: "headbang" })), { state: "bored", frame: "sleepy" }); +assert.strictEqual(state({ idle: 2, music: true }), "yawn"); +assert.strictEqual(state({ idle: 3, music: true }), "lying"); +// Music while awake is the rest pose; the DJ is only the burst. +assert.strictEqual(state({ music: true }), "base"); +assert.deepStrictEqual(M.pick(calm({ djAt: NOW - 10 })), { state: "dj", frame: "dj" }); + // Holds end on time. assert.strictEqual(state({ repliedAt: NOW - M.T.waveHold }), "base"); +assert.strictEqual(state({ singAt: NOW - M.T.singHold }), "base"); +assert.strictEqual(state({ djAt: NOW - M.T.djHold }), "base"); assert.strictEqual(state({ typingAt: NOW - M.T.typingHold }), "base"); assert.strictEqual(state({ wakeAt: NOW - M.T.wakeHold }), "base"); assert.strictEqual(state({ clicks: { count: 3, at: NOW - M.T.clickHold } }), "base"); @@ -95,12 +121,18 @@ assert.deepStrictEqual([1, 2, 3, 4, 5, 6].map(M.clickFrame), // Outfit: the dress until the flash is over, then the leotard for // scorched + skirtless, then the dress again. -assert.strictEqual(M.outfit(0, NOW), "dress"); -assert.strictEqual(M.outfit(NOW - 10, NOW), "dress"); -assert.strictEqual(M.outfit(NOW - M.T.destruct, NOW), "leotard"); -const bare = M.T.destruct + M.T.scorched + M.T.skirtless; -assert.strictEqual(M.outfit(NOW - bare + 10, NOW), "leotard"); -assert.strictEqual(M.outfit(NOW - bare, NOW), "dress"); +const SK = 60000; +assert.strictEqual(M.outfit(0, NOW, SK), "dress"); +assert.strictEqual(M.outfit(NOW - 10, NOW, SK), "dress"); +assert.strictEqual(M.outfit(NOW - M.T.destruct, NOW, SK), "leotard"); +const bare = M.T.destruct + M.T.scorched + SK; +assert.strictEqual(M.outfit(NOW - bare + 10, NOW, SK), "leotard"); +assert.strictEqual(M.outfit(NOW - bare, NOW, SK), "dress"); +// A skirtless time of 0 keeps the leotard until she is shown again, which +// resets destructAt. +assert.strictEqual(M.outfit(NOW - 10, NOW, 0), "dress"); +assert.strictEqual(M.outfit(NOW - 3600000, NOW, 0), "leotard"); +assert.strictEqual(M.outfit(0, NOW, 0), "dress"); // Cooldowns. assert.strictEqual(M.mockAllowed(0, NOW), true); @@ -119,4 +151,28 @@ assert.strictEqual(M.monologue("other", 0, NOW, 0), false); assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown + 1, NOW, 0), false); assert.strictEqual(M.monologue("bored", NOW - M.T.monologueCooldown, NOW, 0), true); +// Admiring a wallpaper: turned to it, then blushing, then done. +assert.deepStrictEqual(M.pick(calm({ admireAt: NOW - 10 })), { state: "admire", frame: "watch" }); +assert.strictEqual(frame({ admireAt: NOW - M.T.admireLook }), "blush3"); +assert.strictEqual(state({ admireAt: NOW - M.T.admireLook - M.T.admireBlush }), "base"); +assert.strictEqual(M.admireAllowed(0, NOW), true); +assert.strictEqual(M.admireAllowed(NOW - M.T.admireCooldown + 1, NOW), false); +assert.strictEqual(M.admireAllowed(NOW - M.T.admireCooldown, NOW), true); + +// The wallpaper list: paths, blank lines and # comments skipped. +assert.deepStrictEqual(L.parse(""), []); +assert.deepStrictEqual(L.parse("# mine\n/a.png\n\n /b.png \n#/c.png\n"), ["/a.png", "/b.png"]); +// Toggling adds a missing path and removes a listed one, leaving +// comments and every other line as they were. +assert.strictEqual(L.toggle("", "/a.png"), "/a.png\n"); +assert.strictEqual(L.toggle("# mine\n/b.png", "/a.png"), "# mine\n/b.png\n/a.png\n"); +assert.strictEqual(L.toggle("# mine\n/a.png\n/b.png\n", "/a.png"), "# mine\n/b.png\n"); +assert.strictEqual(L.toggle("/a.png\n", "/a.png"), ""); +// A commented-out path is not listed, so toggling it adds a live line. +assert.strictEqual(L.toggle("#/a.png\n", "/a.png"), "#/a.png\n/a.png\n"); +// wallp.conf's outputs, by connector. +assert.deepStrictEqual(L.outputs("# x\nOUTPUT_H=DP-1\nOUTPUT_V=DP-3\nDEFAULT_H=~/h.png\n"), + { "DP-1": "H", "DP-3": "V" }); +assert.deepStrictEqual(L.outputs(""), {}); + console.log("ok"); diff --git a/desktop/modules/appearance/WallpaperTab.qml b/desktop/modules/appearance/WallpaperTab.qml index a3b3c55..d9876ed 100644 --- a/desktop/modules/appearance/WallpaperTab.qml +++ b/desktop/modules/appearance/WallpaperTab.qml @@ -12,6 +12,7 @@ import Quickshell import QtQuick import "../.." +import "../../companion" // Wallpapers by folder, staged per screen and set together with Apply, beside // a mock of the two screens above them. Sized by its host: the thumbnail list scrolls @@ -338,6 +339,26 @@ Column { color: Theme.subtext } } + + // The companion blushes at the hearted ones + // (companion/Admired.qml). After the pick + // MouseArea, so it takes the click. Outlined, + // so it reads on a light image too. + Text { + readonly property bool on: Admired.listed(modelData) + anchors { top: parent.top; right: parent.right; margins: 10 } + visible: on || thumbHover.hovered + text: on ? "\u{F02D1}" : "\u{F02D5}" + color: on ? Theme.red : Theme.text + style: Text.Outline + styleColor: Qt.alpha(Theme.base, 0.6) + font { family: Theme.iconFamily; pixelSize: Theme.fontSize + 8 } + MouseArea { + anchors { fill: parent; margins: -4 } + cursorShape: Qt.PointingHandCursor + onClicked: Admired.toggle(modelData) + } + } } } } diff --git a/desktop/modules/assistant/AssistantModule.qml b/desktop/modules/assistant/AssistantModule.qml index 1e3c1e0..a05556d 100644 --- a/desktop/modules/assistant/AssistantModule.qml +++ b/desktop/modules/assistant/AssistantModule.qml @@ -27,7 +27,7 @@ Module { settings: true group: "Assistant" keywords: ["llm", "model", "llama", "server", "prompt", "temperature", "thinking", - "rounds", "tools", "environment", "daemon", "restart"] + "rounds", "tools", "environment", "daemon", "restart", "cloud", "provider"] readonly property string home: Quickshell.env("HOME") readonly property string daemonDir: home + "/Programming/GIT/desktop-assistant" @@ -57,14 +57,34 @@ Module { onDraftChanged: { const k = C.env(am.draft).map(e => e.key); if (JSON.stringify(k) !== JSON.stringify(am.envKeys)) am.envKeys = k; + const n = (C.clouds(am.draft) ?? []).length; + if (n !== am.cloudCount) am.cloudCount = n; } function envValue(key) { return (am.envList.find(e => e.key === key) ?? { value: "" }).value; } - // Tool switches flipped but not saved: name -> wanted on. + // The [[cloud]] tables in the draft, null when one cannot be read (the + // page then leaves them alone), and the saved ones, which the canvas and + // Prefer offer. cloudCount follows the draft's count only, so rows are + // 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; 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 + 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}] @@ -163,14 +183,21 @@ Module { // The config first, then the tool renames, each only when there is // something to do; any failure stops there and says why. - function save() { + function save(done) { if (am.text !== am.base) { am.error = "config.toml changed on disk since these edits began. Discard to load it, then redo them."; return; } am.error = ""; const t = am.draft; - const tools = () => am.applyTools(Object.keys(am.toolsWanted), () => { am.pending = true; am.listTools(); }); + 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; } @@ -183,6 +210,7 @@ Module { am.error = ""; am.invalid = ({}); am.toolsWanted = ({}); + am.cloudWanted = ({}); am.base = am.text; am.draft = am.text; } @@ -197,15 +225,41 @@ Module { function setEnv(key, value) { am.draft = C.setEnv(am.draft, key, value); } function quote(s) { return C.quote(s); } + // patch is {name}, {url}, {key_cmd} or {models}; i === cloudCount adds one. + function editCloud(i, patch) { + const list = (am.clouds ?? []).map(c => Object.assign({}, c)); + if (i === list.length) list.push({ name: "", url: "", key_cmd: "", models: [] }); + Object.assign(list[i], patch); + am.draft = C.setClouds(am.draft, list); + } + function removeCloud(i) { am.draft = C.setClouds(am.draft, am.clouds.filter((c, j) => j !== i)); } + + // The daemon reads the saved config, so Fetch saves the draft first. + // The key command runs in that process; the key never reaches here. + function fetchModels(name) { + am.save(() => { + am.fetching = name; + am.run([am.python, am.daemonDir + "/assistant.py", "--cloud-models", name], null, + (code, out, err) => { + am.fetching = ""; + let got; + try { got = code === 0 ? JSON.parse(out) : (err.trim().split("\n").pop() || `exit ${code}`); } + catch (e) { got = "the assistant sent an unreadable list"; } + am.fetched = Object.assign({}, am.fetched, { [name]: got }); + }); + }); + } + // Every tools/*.toml is loaded; *.toml.off is the same file switched off. readonly property string lister: "import glob, json, os, sys, tomllib\n" + "out = []\n" + "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() { @@ -221,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) { @@ -269,17 +362,17 @@ Module { else { poll.left = 300; poll.start(); } }); } - // Called when the assistant canvas opens: the preferred model, if one is - // set, still listed, and neither loaded nor loading, is loaded. One model - // and one client at a time is how this desktop runs, so opening the - // assistant is the moment to bring its model back. The list is read on - // every open, preferred model or not, so hears is current. - function ensurePreferred() { + // Called when the canvas opens and when its chat's model changes, with + // the chat's model, else the preferred one: loaded if it is a router + // model neither loaded nor loading. A cloud id is not in the router's + // list, so nothing is swapped for it. One model and one client at a time + // is how this desktop runs, so this is the moment to bring the model + // back. The list is read every time, so hears is current. + function ensureModel(id) { if (am.loading !== "") return; am.listModels(() => { - const id = Prefs.assistantModel; const m = am.models.find(x => x.id === id); - if (id !== "" && m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id); + if (m && m.status !== "loaded" && m.status !== "loading") am.loadModel(id); }); } diff --git a/desktop/modules/assistant/AssistantPage.qml b/desktop/modules/assistant/AssistantPage.qml index e088241..bf35763 100644 --- a/desktop/modules/assistant/AssistantPage.qml +++ b/desktop/modules/assistant/AssistantPage.qml @@ -166,9 +166,9 @@ Column { Small { width: page.width wrapMode: Text.Wrap - text: "The assistant answers with whichever model llama-server has loaded. Opening it loads " - + "the preferred model if another one is in. Load swaps a model in now, for every client, " - + "without changing the preference." + text: "Each chat uses the model picked in the canvas header, else the preferred one. " + + "Opening the canvas loads that model if it is a local one and another is in. " + + "Load swaps a model in now, for every client, without changing the preference." } Small { @@ -231,6 +231,50 @@ Column { } } + // The saved cloud models: Prefer only, there is nothing to load. + Repeater { + model: page.asst.cloudIds + + delegate: Rectangle { + id: cloudRow + required property string modelData + readonly property bool preferred: Prefs.assistantModel === modelData + objectName: "model:" + modelData + width: page.width + height: 40 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + // nf-fa-cloud, present in Inconsolata Nerd Font. + Text { + id: cloudIcon + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + id: cloudName + anchors { left: cloudIcon.right; leftMargin: 8; verticalCenter: parent.verticalCenter } + text: cloudRow.modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + Small { + anchors { left: cloudName.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + visible: cloudRow.preferred + text: "preferred" + color: Theme.accent + } + Button { + anchors { right: parent.right; rightMargin: 6; verticalCenter: parent.verticalCenter } + text: cloudRow.preferred ? "Preferred" : "Prefer" + enabled: !cloudRow.preferred + onClicked: Prefs.setAssistantModel(cloudRow.modelData) + } + } + } + Small { width: page.width visible: page.asst.modelError !== "" @@ -368,6 +412,141 @@ Column { // --- env --- + // --- cloud --- + + Heading { text: "Cloud" } + + Small { + width: page.width + wrapMode: Text.Wrap + text: "Providers with an OpenAI-compatible API, offered in the canvas's model menu. A cloud " + + "chat is sent its history, attachments and the results of the tools marked " + + "cloud = true; voice is still transcribed here. The key command prints the API key: " + + "the assistant runs it at startup and on Fetch, which saves this page first." + } + + Small { + width: page.width + visible: page.asst.clouds === null + wrapMode: Text.Wrap + text: "config.toml has a [[cloud]] table this page cannot read; edit it by hand." + color: Theme.red + } + + Repeater { + model: page.asst.clouds === null ? 0 : page.asst.cloudCount + + delegate: Rectangle { + id: prov + required property int index + readonly property var c: page.asst.clouds?.[index] ?? { name: "", url: "", key_cmd: "", models: [] } + readonly property var got: page.asst.fetched[prov.c.name] + objectName: "cloud:" + index + width: page.width + height: provCol.implicitHeight + 20 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Column { + id: provCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 6 + + Labelled { + width: provCol.width + label: "Name" + Field { + anchors.fill: parent + value: prov.c.name + placeholder: "openrouter" + onEdited: t => page.asst.editCloud(prov.index, { name: t.trim() }) + } + } + Labelled { + width: provCol.width + label: "URL" + Field { + anchors.fill: parent + value: prov.c.url + placeholder: "https://openrouter.ai/api/v1" + onEdited: t => page.asst.editCloud(prov.index, { url: t.trim() }) + } + } + Labelled { + width: provCol.width + label: "Key command" + Field { + anchors.fill: parent + value: prov.c.key_cmd + placeholder: "pass show llm/openrouter" + onEdited: t => page.asst.editCloud(prov.index, { key_cmd: t.trim() }) + } + } + Row { + spacing: 6 + Button { + text: page.asst.fetching === prov.c.name ? "Fetching…" : "Fetch models" + enabled: page.asst.fetching === "" && page.asst.valid && prov.c.name !== "" + onClicked: page.asst.fetchModels(prov.c.name) + } + Button { + text: "Remove" + danger: true + onClicked: page.asst.removeCloud(prov.index) + } + } + Small { + width: provCol.width + visible: typeof prov.got === "string" + wrapMode: Text.Wrap + text: typeof prov.got === "string" ? prov.got : "" + color: Theme.red + } + // The fetched ids and any kept from before, each switched + // into models or out of it. + Repeater { + model: Array.isArray(prov.got) + ? [...new Set(prov.got.concat(prov.c.models))].sort() : prov.c.models + + delegate: Item { + id: idRow + required property string modelData + width: provCol.width + height: 28 + Switch { + id: idOn + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + checked: prov.c.models.includes(idRow.modelData) + onToggled: on => page.asst.editCloud(prov.index, { models: on + ? prov.c.models.concat([idRow.modelData]) + : prov.c.models.filter(m => m !== idRow.modelData) }) + } + Text { + anchors { left: idOn.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: idRow.modelData + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + } + } + } + } + + Small { + width: page.width + visible: page.asst.cloudError !== "" + wrapMode: Text.Wrap + text: page.asst.cloudError + color: Theme.red + } + + Button { + text: "Add provider" + enabled: page.asst.clouds !== null + onClicked: page.asst.editCloud(page.asst.cloudCount, {}) + } + Heading { text: "Tool environment" } Small { width: page.width @@ -461,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 { @@ -480,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/Config.js b/desktop/modules/assistant/Config.js index c30ea8b..4c71b14 100644 --- a/desktop/modules/assistant/Config.js +++ b/desktop/modules/assistant/Config.js @@ -17,7 +17,8 @@ // The file is edited line by line so its comments survive: the example it // is copied from documents every key in place. Only the shapes this page // writes are understood: top-level `key = value` before the first table, -// and `KEY = "value"` lines in [env]. A top-level key that is unset sits +// and `KEY = "value"` lines in [env]. [[cloud]] tables are read when they +// hold only what setClouds writes, and rewritten whole. A top-level key that is unset sits // commented out (`# key = value`), which is how the example ships, so // setting one activates its line and unsetting comments it back out. // ponytail: no multi-line strings or arrays; the page never writes one, @@ -140,3 +141,87 @@ function setEnv(text, key, val) { lines.splice(last + 1, 0, key + " = " + quote(val)); return lines.join("\n"); } + +// [[cloud]] tables, the daemon's providers: name, url and key_cmd strings +// and a one-line models array, as setClouds writes them. +const CLOUD = /^\s*\[\[cloud\]\]\s*(#.*)?$/; + +// A one-line array of strings, ["a", "b"]; null for anything else. +function strings(rest) { + let r = rest.trim(); + if (!r.startsWith("[")) return null; + const out = []; + r = r.slice(1).trim(); + while (!r.startsWith("]")) { + if (!r.startsWith('"')) return null; + const v = value(r); + out.push(v.v); + r = r.slice(v.end).trim(); + if (r.startsWith(",")) r = r.slice(1).trim(); + else if (!r.startsWith("]")) return null; + } + const tail = r.slice(1).trim(); + return tail === "" || tail.startsWith("#") ? out : null; +} + +// [{start, end}] of each [[cloud]] table, its header to the next header. +function cloudBlocks(lines) { + const out = []; + lines.forEach((l, i) => { + if (!CLOUD.test(l)) return; + const e = lines.findIndex((x, j) => j > i && HEADER.test(x)); + out.push({ start: i, end: e < 0 ? lines.length : e }); + }); + return out; +} + +// [{name, url, key_cmd, models}] in file order, or null when a table holds +// a line this cannot read (a multi-line array, another key): the page then +// leaves them to be edited by hand rather than rewrite what it did not read. +function clouds(text) { + const lines = text.split("\n"); + const out = []; + for (const b of cloudBlocks(lines)) { + const c = { name: "", url: "", key_cmd: "", models: [] }; + for (let i = b.start + 1; i < b.end; i++) { + if (/^\s*(#.*)?$/.test(lines[i])) continue; + const m = KEY.exec(lines[i]); + if (!m) return null; + if (m[3] === "models") { + const a = strings(m[4]); + if (a === null) return null; + c.models = a; + } else if (["name", "url", "key_cmd"].includes(m[3]) && m[4].startsWith('"')) { + c[m[3]] = value(m[4]).v; + } else return null; + } + out.push(c); + } + return out; +} + +// Replaces every [[cloud]] table with list's, at the end of the file. The +// lines inside the old tables go with them; comments above them stay. +function setClouds(text, list) { + const lines = text.split("\n"); + for (const b of cloudBlocks(lines).reverse()) lines.splice(b.start, b.end - b.start); + while (lines.length && lines[lines.length - 1].trim() === "") lines.pop(); + for (const c of list) + lines.push("", "[[cloud]]", "name = " + quote(c.name), "url = " + quote(c.url), + "key_cmd = " + quote(c.key_cmd), + "models = [" + c.models.map(quote).join(", ") + "]"); + return lines.join("\n") + "\n"; +} + +// Why list cannot be saved, "" when it can: the daemon refuses to start on +// any of these (assistant.py, check_cloud). +function cloudError(list) { + for (const c of list) { + if (!c.name.trim() || !c.url.trim() || !c.key_cmd.trim()) + return "A cloud provider needs a name, a URL and a key command."; + if (c.name.includes(":")) return `Cloud name ${c.name} has a colon.`; + } + const names = list.map(c => c.name); + const dup = names.find((n, i) => names.indexOf(n) !== i); + return dup !== undefined ? `Cloud name ${dup} is used twice.` : ""; +} diff --git a/desktop/modules/assistant/README.md b/desktop/modules/assistant/README.md index 3c35dc6..a1ce840 100644 --- a/desktop/modules/assistant/README.md +++ b/desktop/modules/assistant/README.md @@ -13,7 +13,17 @@ 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 +--cloud-models <name>`, which runs the key command and lists the +provider's ids; the switches choose which the canvas offers. The key +never reaches this page. A table holding more than the page writes (a +multi-line array, another key) is left alone, with a note to edit it by +hand. The saved cloud models also get Prefer in the Model list. The page also lists llama-server's models with their state. The daemon always answers with whichever model is loaded and never asks for one, so @@ -24,12 +34,12 @@ Prefer is the other, separate choice: the model the assistant should use, saved in `Prefs`. A first version made Load also set the preference, so a model loaded for another client became the assistant's too. -Opening the assistant canvas (SUPER+Home) loads the preferred model when -another one is resident, and the canvas header reads "loading <model>…" -until it is in. This desktop runs one model and one client at a time, so -opening the assistant is when its model should come back; the daemon still -never asks for a model itself. With no preferred model set, nothing is -swapped. +Each chat uses the model picked in the canvas's model menu, else the +preferred one, else whatever is loaded. Opening the canvas, or picking a +local model for its chat, loads that model when another one is resident, +and the canvas header reads "loading <model>…" until it is in. This +desktop runs one model and one client at a time, so that is when the +model should come back. A cloud model loads nothing. The daemon reads all of this only at startup, so after a change the page says so and offers Restart, which runs `assistant.sh restart`; a daemon that diff --git a/desktop/modules/assistant/test_config.js b/desktop/modules/assistant/test_config.js index cc610a8..f79f4e1 100644 --- a/desktop/modules/assistant/test_config.js +++ b/desktop/modules/assistant/test_config.js @@ -18,7 +18,7 @@ const assert = require("assert"); const src = fs.readFileSync(__dirname + "/Config.js", "utf8") .replace(/^\.pragma library$/m, ""); -const C = new Function(src + "\nreturn { scalars, setScalar, env, setEnv, quote };")(); +const C = new Function(src + "\nreturn { scalars, setScalar, env, setEnv, quote, clouds, setClouds, cloudError };")(); const KEYS = ["llm", "system", "tool_rounds", "temperature", "thinking"]; const file = [ @@ -86,4 +86,43 @@ assert.ok(/\n\[env\]\nX = "1"\n$/.test(t), JSON.stringify(t)); // An env key in another table is not the env one. assert.deepStrictEqual(C.env('[voice]\nlang = "it"\n'), []); +// [[cloud]] tables. +const withCloud = file + [ + "# Cloud models.", + "[[cloud]]", + 'name = "hetzner"', + 'url = "https://llm.example.org/v1" # a comment', + 'key_cmd = "pass llm/x"', + 'models = ["a/b", "c:d"]', + "", + "[[cloud]]", + 'name = "two"', + 'url = "u"', + 'key_cmd = "k"', + "models = []", + "", +].join("\n"); +assert.deepStrictEqual(C.clouds(withCloud), [ + { name: "hetzner", url: "https://llm.example.org/v1", key_cmd: "pass llm/x", models: ["a/b", "c:d"] }, + { name: "two", url: "u", key_cmd: "k", models: [] }]); +assert.deepStrictEqual(C.clouds(file), []); +assert.deepStrictEqual(C.env(withCloud), C.env(file)); // [env] ends at the table +// A table this cannot read in full is not offered for editing. +assert.strictEqual(C.clouds(withCloud.replace('models = ["a/b", "c:d"]', 'models = [\n "a/b",\n]')), null); +assert.strictEqual(C.clouds(withCloud.replace('name = "two"', 'name = "two"\nextra = 1')), null); +// Rewritten at the end of the file; everything else stays. +const one = C.setClouds(withCloud, [{ name: "n", url: "u", key_cmd: 'pass "q"', models: ["m"] }]); +assert.deepStrictEqual(C.clouds(one), [{ name: "n", url: "u", key_cmd: 'pass "q"', models: ["m"] }]); +assert.deepStrictEqual(C.env(one), C.env(file)); +assert.deepStrictEqual(C.scalars(one, KEYS), C.scalars(file, KEYS)); +assert.ok(one.includes("# Cloud models.") && one.endsWith('models = ["m"]\n'), one); +assert.deepStrictEqual(C.clouds(C.setClouds(one, [])), []); +// What the daemon refuses to start with. +const ok = { name: "a", url: "u", key_cmd: "k", models: [] }; +assert.strictEqual(C.cloudError([ok]), ""); +assert.strictEqual(C.cloudError([]), ""); +for (const bad of [{ name: "" }, { name: "a:b" }, { url: "" }, { key_cmd: " " }]) + assert.notStrictEqual(C.cloudError([Object.assign({}, ok, bad)]), "", JSON.stringify(bad)); +assert.notStrictEqual(C.cloudError([ok, ok]), ""); + console.log("test_config: ok"); diff --git a/desktop/modules/companion/ArtistsModule.qml b/desktop/modules/companion/ArtistsModule.qml new file mode 100644 index 0000000..a0d23f4 --- /dev/null +++ b/desktop/modules/companion/ArtistsModule.qml @@ -0,0 +1,67 @@ +// 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. + +import QtQuick +import "../.." +import "../../companion/Lines.js" as Lines + +// The lines the companion says when a track by an artist she knows starts, +// matched as a case-insensitive substring of the player's artist. Same +// draft, Save and Reset as PhrasesModule, on Prefs.companionArtists and +// Lines.artists; the page's cls field holds the artist. +Module { + id: am + name: "artists" + label: "Companion artists" + // nf-fa-music, present in Inconsolata Nerd Font. + icon: "\uf001" + settings: true + group: "Companion" + keywords: ["companion", "artist", "music", "song", "track", "line", "say", "2b"] + searchItems: am.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i })) + + property var draft: null + property bool resetting: false + readonly property var shown: am.draft ?? Lines.appList(Prefs.companionArtists, Lines.artists) + readonly property bool valid: am.shown.every(a => String(a.cls).trim() !== "" + && (a.lines || []).some(l => String(l).trim() !== "")) + dirty: am.draft !== null + + function edit(list) { am.draft = list; am.resetting = false; } + function reset() { am.draft = Lines.appList(null, Lines.artists); am.resetting = true; } + function save() { + if (am.resetting) Prefs.resetCompanionArtists(); + else Prefs.setCompanionArtists(Lines.toMap(am.draft)); + am.discard(); + } + function discard() { am.draft = null; am.resetting = false; } + + page: Component { + Page { + title: "Companion artists" + actions: Component { + SaveBar { dirty: am.dirty; valid: am.valid; note: "every artist needs a name and a phrase" + onSave: am.save(); onDiscard: am.discard() } + } + PhrasesPage { + width: parent.width + owner: am + intro: "When a track by an artist she knows starts or changes, she says one of their lines at " + + "random, under her 30 s line cooldown. The name matches any part of the player's artist, " + + "ignoring case, so \"daft punk\" also catches \"Daft Punk, Pharrell Williams\". " + + "Save applies at once." + keyHint: "artist, e.g. linkin park" + addText: "Add artist" + saved: Prefs.companionArtists + } + } + } +} diff --git a/desktop/modules/companion/CompanionModule.qml b/desktop/modules/companion/CompanionModule.qml index ea6b13d..8d6a68d 100644 --- a/desktop/modules/companion/CompanionModule.qml +++ b/desktop/modules/companion/CompanionModule.qml @@ -11,42 +11,117 @@ import QtQuick import "../.." +import "../appearance" +import "../../companion" -// The desktop companion's one setting: whether she is shown. The same -// preference SUPER+\ toggles, so the switch follows the key. +// The desktop companion's settings: whether she is shown, the same +// preference SUPER+\ toggles, so the switch follows the key; and where her +// wallpaper list is, marked with the heart in the wallpaper picker. Module { name: "companion" label: "Companion" // nf-md-account_heart, present in Inconsolata Nerd Font. icon: "\u{F0899}" settings: true - group: "Desktop" - keywords: ["companion", "2b", "mascot", "character", "show", "hide"] + group: "Companion" + keywords: ["companion", "2b", "mascot", "character", "show", "hide", "wallpaper", "blush", "leotard", "timeout"] page: Component { Page { title: "Companion" - Item { + Column { width: parent.width - height: 34 - Text { - id: lbl - anchors { left: parent.left; verticalCenter: parent.verticalCenter } - width: 160 - text: "Shown" - color: Theme.subtext - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + spacing: 6 + + Item { + width: parent.width + height: 34 + Text { + id: lbl + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + text: "Shown" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Switch { + id: sw + anchors { left: lbl.right; leftMargin: 8; verticalCenter: parent.verticalCenter } + checked: Prefs.companion + onToggled: on => Prefs.setCompanion(on) + } + Text { + anchors { left: sw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: "Also SUPER+\\. Kept across restarts." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + } + // After the self-destruct. Off keeps the leotard until she is + // hidden and shown again; turning it back on starts from the + // seconds in the field. + Item { + width: parent.width + height: 34 + Text { + id: lblSk + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + text: "Leotard timeout" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Switch { + id: swSk + anchors { left: lblSk.right; leftMargin: 8; verticalCenter: parent.verticalCenter } + checked: Prefs.companionSkirtless > 0 + onToggled: on => Prefs.setCompanionSkirtless(on ? (parseInt(fSk.value) || 60) : 0) + } + Field { + id: fSk + anchors { left: swSk.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + width: 60 + enabled: swSk.checked + opacity: enabled ? 1 : 0.4 + value: String(Prefs.companionSkirtless || 60) + placeholder: "60" + onEdited: { + const n = parseInt(value); + if (n >= 1 && String(n) === value.trim()) Prefs.setCompanionSkirtless(n); + else value = String(Prefs.companionSkirtless || 60); + } + } + Text { + anchors { left: fSk.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: swSk.checked ? "seconds" : "until she is hidden and shown again" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } } - Switch { - id: sw - anchors { left: lbl.right; leftMargin: 8; verticalCenter: parent.verticalCenter } - checked: Prefs.companion - onToggled: on => Prefs.setCompanion(on) + Item { + width: parent.width + height: 34 + Text { + id: lbl2 + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 160 + text: "Wallpaper list" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Field { + anchors { left: lbl2.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter } + value: Prefs.companionBlushFile + placeholder: Admired.defaultPath.replace(Admired.home, "~") + onEdited: Prefs.setCompanionBlushFile(value) + } } Text { - anchors { left: sw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter } - elide: Text.ElideRight - text: "Also SUPER+\\. Kept across restarts." + width: parent.width + wrapMode: Text.WordWrap + text: "One path per line. She blushes at these on an empty workspace; the heart in Wallpaper adds and removes them." color: Theme.overlay font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } } diff --git a/desktop/modules/companion/PhrasesModule.qml b/desktop/modules/companion/PhrasesModule.qml new file mode 100644 index 0000000..7e4895a --- /dev/null +++ b/desktop/modules/companion/PhrasesModule.qml @@ -0,0 +1,64 @@ +// 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. + +import QtQuick +import "../.." +import "../../companion/Lines.js" as Lines + +// The lines the companion says when focus moves to a program she +// recognizes, by its lowercased window class. They live in Prefs and she +// reads them live, so a save needs no restart. The page edits a draft here; +// Save hands it to Prefs, Reset drops the saved set so Lines.js' defaults +// show again. +Module { + id: pm + name: "phrases" + label: "Companion phrases" + // nf-fa-comment, present in Inconsolata Nerd Font. + icon: "\uf075" + settings: true + group: "Companion" + keywords: ["companion", "phrase", "line", "say", "speak", "app", "program", + "window", "class", "firefox", "2b"] + searchItems: pm.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i })) + + // null while there are no edits: the page then shows the saved set, or + // Lines.js' defaults when nothing is saved. + property var draft: null + // Reset was pressed: Save drops the saved set rather than saving a copy + // of the defaults, so later default changes still arrive. + property bool resetting: false + readonly property var shown: pm.draft ?? Lines.appList(Prefs.companionApps) + // Every app needs a class and at least one non-empty phrase; Save waits. + readonly property bool valid: pm.shown.every(a => String(a.cls).trim() !== "" + && (a.lines || []).some(l => String(l).trim() !== "")) + dirty: pm.draft !== null + + function edit(list) { pm.draft = list; pm.resetting = false; } + function reset() { pm.draft = Lines.appList(null); pm.resetting = true; } + function save() { + if (pm.resetting) Prefs.resetCompanionApps(); + else Prefs.setCompanionApps(Lines.toMap(pm.draft)); + pm.discard(); + } + function discard() { pm.draft = null; pm.resetting = false; } + + page: Component { + Page { + title: "Companion phrases" + actions: Component { + SaveBar { dirty: pm.dirty; valid: pm.valid; note: "every app needs a class and a phrase" + onSave: pm.save(); onDiscard: pm.discard() } + } + PhrasesPage { width: parent.width; owner: pm } + } + } +} diff --git a/desktop/modules/companion/PhrasesPage.qml b/desktop/modules/companion/PhrasesPage.qml new file mode 100644 index 0000000..1971171 --- /dev/null +++ b/desktop/modules/companion/PhrasesPage.qml @@ -0,0 +1,195 @@ +// 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. + +import QtQuick +import "../.." + +// One card per recognized program: its window class and the lines she says +// when focus moves to it. Every change edits the module's draft; the +// header's Save hands the set to Prefs. Reset fills the draft with the +// built-in set from Lines.js. +Column { + id: page + spacing: 10 + + // The module, holding the draft. Not named pm, its id (it would bind to + // itself, AGENTS.md). + required property QtObject owner + // What the cards are keyed by; the defaults are the app set's, so + // PhrasesModule passes nothing. + property string intro: "When focus moves to a program she recognizes, she says one of its lines at random, a third " + + "of the time and under her 30 s line cooldown. The class is Hyprland's window class, " + + "lowercased (hyprctl activewindow shows it). Save applies at once." + property string keyHint: "window class, e.g. firefox" + property string addText: "Add app" + // The saved set, for Reset's enabled state; null means the defaults. + property var saved: Prefs.companionApps + readonly property var list: owner.shown + // The app card awaiting a second click on Remove. + property int confirming: -1 + + // Saves a copy of the list with app i replaced by fn(app), or removed + // when fn returns null. + function edit(i, fn) { + const out = []; + for (let j = 0; j < page.list.length; j++) { + const a = page.list[j]; + const c = { cls: a.cls, lines: a.lines.slice() }; + if (j !== i) { out.push(c); continue; } + const r = fn(c); + if (r) out.push(r); + } + page.confirming = -1; + page.owner.edit(out); + } + + function addApp() { page.owner.edit(page.list.concat([{ cls: "", lines: [""] }])); } + function removeApp(i) { page.edit(i, () => null); } + function setCls(i, t) { page.edit(i, a => Object.assign(a, { cls: t })); } + function addLine(i) { page.edit(i, a => Object.assign(a, { lines: a.lines.concat([""]) })); } + function setLine(i, j, t) { + page.edit(i, a => { + const ls = a.lines.slice(); + ls[j] = t; + return Object.assign(a, { lines: ls }); + }); + } + function removeLine(i, j) { page.edit(i, a => Object.assign(a, { lines: a.lines.filter((_, k) => k !== j) })); } + + component Small: Text { + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + // Reports every keystroke into the draft. Not bound to value, which + // would reset the cursor; it follows the value only when the draft moves + // under it (Discard, Reset). Empty is outlined, since Save refuses it. + component Field: Rectangle { + id: field + property string value: "" + property string placeholder: "" + signal edited(string text) + height: 32 + radius: 8 + color: Qt.alpha(Theme.base, 0.6) + border.width: ti.text.trim() === "" ? 1 : 0 + border.color: Theme.red + onValueChanged: if (ti.text !== field.value) ti.text = field.value + TextInput { + activeFocusOnTab: true + id: ti + anchors { fill: parent; leftMargin: 10; rightMargin: 10 } + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + Component.onCompleted: text = field.value + onTextEdited: field.edited(text) + Text { + anchors.verticalCenter: parent.verticalCenter + visible: parent.text === "" + text: field.placeholder + color: Theme.overlay + font: parent.font + } + } + } + + Small { + width: page.width + wrapMode: Text.Wrap + text: page.intro + } + + Repeater { + // A count, not the list: a new list on every keystroke would rebuild + // every card and drop the focus of the field being typed in. + model: page.list.length + + delegate: Rectangle { + id: card + required property int index + readonly property var modelData: page.list[index] ?? { cls: "", lines: [] } + objectName: "app:" + index + width: page.width + height: body.implicitHeight + 20 + radius: 10 + color: Qt.alpha(Theme.surface, 0.45) + + Column { + id: body + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 8 + + Item { + width: parent.width + height: 32 + Field { + anchors { left: parent.left; right: del.left; rightMargin: 8; top: parent.top; bottom: parent.bottom } + value: card.modelData.cls + placeholder: page.keyHint + onEdited: t => page.setCls(card.index, t) + } + Button { + id: del + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + danger: true + text: page.confirming === card.index ? "Confirm" : "Remove" + onClicked: { + if (page.confirming === card.index) page.removeApp(card.index); + else page.confirming = card.index; + } + } + } + + Repeater { + model: card.modelData.lines.length + delegate: Row { + id: phraseRow + required property int index + width: body.width + spacing: 6 + Field { + width: phraseRow.width - removeBtn.width - phraseRow.spacing + value: card.modelData.lines[phraseRow.index] ?? "" + placeholder: "a line she says" + onEdited: t => page.setLine(card.index, phraseRow.index, t) + } + Button { + id: removeBtn + // nf-fa-xmark, present in Inconsolata Nerd Font. + icon: "\uf00d" + danger: true + onClicked: page.removeLine(card.index, phraseRow.index) + } + } + } + + Button { + text: "Add phrase" + onClicked: page.addLine(card.index) + } + } + } + } + + Row { + spacing: 8 + Button { text: page.addText; onClicked: page.addApp() } + Button { + text: "Reset to defaults" + danger: true + enabled: !page.owner.resetting && (page.saved !== null || page.owner.dirty) + onClicked: page.owner.reset() + } + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index 4b4f47f..35dbe3f 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -88,6 +88,8 @@ ShellRoot { SunsetModule {}, IdleModule {}, CompanionModule {}, + PhrasesModule {}, + ArtistsModule {}, NotificationsModule {}, AutostartModule {}, ObsScenesModule {}, @@ -126,17 +128,20 @@ ShellRoot { // The assistant canvas on SUPER+Home, a view over desktop-assistant's // socket. See assistant/README.md. - Assistant { id: assistant; swapping: asstSettings.loading; hears: asstSettings.hears } + Assistant { id: assistant; swapping: asstSettings.loading; hears: asstSettings.hears + preferred: Prefs.assistantModel } // The desktop companion, bottom-right of the focused monitor, on SUPER+\. // She reads the assistant's state. See companion/README.md. Companion { asst: assistant } - // Opening the assistant loads its preferred model if another one is in - // VRAM. See modules/assistant/README.md. + // Opening the assistant, or picking another model for its chat, loads + // that model if it is a local one and another is in VRAM. See + // modules/assistant/README.md. Connections { target: assistant - function onOpenChanged() { if (assistant.open) asstSettings.ensurePreferred(); } + function onOpenChanged() { if (assistant.open) asstSettings.ensureModel(assistant.wanted); } + function onWantedChanged() { if (assistant.open) asstSettings.ensureModel(assistant.wanted); } } // The waybar launcher and the deep-link keybinds all reach this: |
