Files
Panama/config/dot/quickshell/services/Dictation.qml
T
Gabriel Brown 14fc4fc8a3 Make the dictation setup button call a command that exists
The Download button on Sound → Dictation ran `panama-dictate download`. There
is no such command: the helper answered {"ok": false, "error":
"unknown-command"} and the button did nothing, every time, for anyone who
tried it. The real command is `setup`, which pulls the speech server image
and downloads the ~490 MB model together -- one trip, by design. And the card
told you the server needed `panama app whisper-vulkan`, an app that exists in
no package or setup/apps entry; the server is a prebuilt image the helper
pulls with podman.

So the card described three things and got all three wrong. It now shows the
real state of both pieces and offers one action -- "Set up dictation" -- wired
to the command that actually fetches them, with progress that names which half
is running (server image, then model). Verified end to end: `panama-dictate
setup` emits the pulling/downloading JSON the service parses.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 13:59:42 -04:00

139 lines
5.7 KiB
QML

pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Dictation: the two pieces that have to be present before holding the key does
// anything, and the download of the one that is not a package.
//
// Neither piece is installed by ./install, deliberately. The model is half a
// gigabyte that no repository carries, and the speech server is a container
// image compiled from upstream -- both are slow, both want the network, and an
// installer that quietly does either is the surprise the interview exists to
// prevent. So this reports what is missing and offers the part it can do.
//
// Status is read from the helper rather than inferred. Whether an image exists
// and whether a server is answering are facts about the machine, and a page
// that guessed at them would be confidently wrong on exactly the machine where
// somebody is trying to work out why dictation is silent.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helper: Quickshell.shellDir + "/scripts/panama-dictate"
property bool modelInstalled: false
property bool imageBuilt: false
property bool serverReady: false
property bool typingAvailable: false
property int modelBytes: 0
property bool downloading: false
// "pulling" while the server image comes down, "downloading" while the
// model does. Empty when idle. Lets the button say which half is running.
property string phase: ""
property int downloadedBytes: 0
property int downloadTotalBytes: 0
property string lastError: ""
readonly property bool ready: root.modelInstalled && root.imageBuilt
readonly property real downloadFraction: root.downloadTotalBytes > 0
? Math.min(1, root.downloadedBytes / root.downloadTotalBytes)
: 0
// What is missing, in the order it has to be fixed. The image first: the
// model is useless without something to run it, and telling somebody to
// download half a gigabyte before mentioning they also need to build an
// image would be two trips.
readonly property string missing: {
if (!root.imageBuilt)
return "image";
if (!root.modelInstalled)
return "model";
return "";
}
Process {
id: statusRun
command: [root.helper, "status"]
stdout: StdioCollector {
onStreamFinished: {
try {
const state = JSON.parse(this.text);
root.modelInstalled = state.modelInstalled === true;
root.imageBuilt = state.imageBuilt === true;
root.serverReady = state.serverReady === true;
root.typingAvailable = state.typingAvailable === true;
root.modelBytes = state.modelBytes ?? 0;
} catch (error) {
root.lastError = "Could not read dictation status.";
}
}
}
}
Process {
id: downloadRun
// "setup", not "download": the helper has no "download" command -- it
// has one "setup" that pulls the server image AND downloads the model,
// because being told to fetch half a gigabyte only to hear an image is
// also needed would be two trips. Calling the wrong name is why this
// button did nothing for so long.
command: [root.helper, "setup"]
stdout: SplitParser {
// One JSON object per line, roughly once a second, so the progress
// bar moves without the helper flooding a pipe nobody drains.
onRead: line => {
try {
const update = JSON.parse(line);
if (update.ok === false) {
root.lastError = "The model could not be downloaded.";
return;
}
if (update.state !== undefined)
root.phase = String(update.state);
if (update.bytes !== undefined)
root.downloadedBytes = update.bytes;
if (update.total !== undefined)
root.downloadTotalBytes = update.total;
} catch (error) {
// A line that is not JSON is not worth failing a download
// over; the exit status is what decides.
}
}
}
onExited: (exitCode, exitStatus) => {
root.downloading = false;
root.phase = "";
if (exitCode !== 0 && root.lastError === "")
root.lastError = "Dictation setup did not finish. Check that podman is installed and the network is up.";
root.refresh();
}
}
function refresh(): void {
if (!statusRun.running)
statusRun.running = true;
}
// Pulls the speech server image and downloads the model, with progress.
// One action because the helper's one "setup" command does both.
function setup(): void {
if (downloadRun.running)
return;
root.lastError = "";
root.phase = "";
root.downloadedBytes = 0;
root.downloadTotalBytes = 0;
root.downloading = true;
downloadRun.running = true;
}
Component.onCompleted: root.refresh()
}