diff --git a/hosts/FredOS-Mediaserver.nix b/hosts/FredOS-Mediaserver.nix index 678aa2b..5b55ce2 100644 --- a/hosts/FredOS-Mediaserver.nix +++ b/hosts/FredOS-Mediaserver.nix @@ -60,6 +60,55 @@ esac done '') + # Instant-answer backend for the quickshell launcher on the desktops: + # `printf '%s' "question" | ssh mediaserver qs-ask`. The Anthropic key + # lives here and nowhere else, so the desktops hold no credential and + # there's a single place to rotate it. + # + # Reads the question from stdin — passing it as an ssh argv element would + # send it through the remote shell for a second round of word splitting. + # + # Haiku 4.5 is the cheapest model and ample for a one-line factual + # answer: ~$0.0005 a query at $1/$5 per million input/output tokens. No + # `thinking` and no `effort` — `effort` errors on Haiku 4.5, and a + # one-sentence fact needs no reasoning tokens. + # + # Set the key up once (fred's own file, no sudo needed): + # mkdir -p ~/.config/anthropic + # printf '%s' sk-ant-... > ~/.config/anthropic/api-key + # chmod 600 ~/.config/anthropic/api-key + # + # Prints nothing on a missing key, a failed call, or an UNKNOWN reply — + # the launcher reads silence as "fall back to Wikipedia". + (pkgs.writeShellScriptBin "qs-ask" '' + q=$(${pkgs.coreutils}/bin/cat) + [ -z "$q" ] && exit 0 + key_file="$HOME/.config/anthropic/api-key" + [ -r "$key_file" ] || exit 0 + key=$(${pkgs.coreutils}/bin/tr -d '\n' < "$key_file") + [ -z "$key" ] && exit 0 + + # jq builds the body, so a question containing quotes or backslashes + # can't break out of the JSON string. + body=$(${pkgs.jq}/bin/jq -n --arg q "$q" '{ + model: "claude-haiku-4-5", + max_tokens: 300, + system: "Answer the question in one or two short sentences, under 240 characters. Lead with the specific fact asked for, including units. No preamble, no caveats, no markdown, no follow-up offers. If you do not know, or the answer depends on live data you do not have, reply with exactly: UNKNOWN", + messages: [ { role: "user", content: $q } ] + }') + + ans=$(${pkgs.curl}/bin/curl -sf --max-time 10 https://api.anthropic.com/v1/messages \ + -H 'content-type: application/json' \ + -H "x-api-key: $key" \ + -H 'anthropic-version: 2023-06-01' \ + --data-raw "$body" \ + | ${pkgs.jq}/bin/jq -r '[.content[]? | select(.type == "text") | .text] | join(" ")') + + # UNKNOWN, a refusal (empty content), or any transport error: stay quiet + # so the launcher's Wikipedia path answers instead. + case "$ans" in ""|null|UNKNOWN*) exit 0 ;; esac + printf '%s' "$ans" + '') ]; # Basic networking diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 487eaaf..fce0e7d 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -247,6 +247,28 @@ in 'https://en.wikipedia.org/w/api.php?action=query&format=json&generator=search&gsrlimit=3&prop=extracts&exintro&explaintext&redirects=1' ''; + # Launcher LLM answers, via the mediaserver. The API key lives on the + # server ONLY — the desktops just pipe a question over the same SSH + # path the server monitor already uses, so no laptop ever holds a + # credential and there's one place to rotate it. The remote end is + # `qs-ask` in hosts/FredOS-Mediaserver.nix. + # + # The query goes over STDIN, not as an argv element: `ssh host cmd arg` + # re-parses arg through the remote shell, so a question containing + # quotes would break. stdin sidesteps remote quoting entirely. + # + # Prints nothing when the key is missing, the server is unreachable, the + # call fails, or the model says it doesn't know — each of those falls + # through to the Wikipedia answer. + askFetchScript = pkgs.writeShellScript "ask-fetch" '' + q="$1" + [ -z "$q" ] && exit 0 + printf '%s' "$q" | ${pkgs.openssh}/bin/ssh \ + -o BatchMode=yes -o ConnectTimeout=4 \ + -o StrictHostKeyChecking=accept-new \ + fred@10.0.0.1 qs-ask 2>/dev/null + ''; + # cava in raw mode: one newline-terminated frame per tick, bars as # semicolon-separated 0-100 ints — trivially parsed by SplitParser. # Drives the media card's spectrum ring. Only spawned while the @@ -755,6 +777,7 @@ in readonly property string wlCopy: "${pkgs.wl-clipboard}/bin/wl-copy" readonly property string fd: "${pkgs.fd}/bin/fd" readonly property string answerFetch: "${answerFetchScript}" + readonly property string askFetch: "${askFetchScript}" readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open" } ''; @@ -1683,7 +1706,9 @@ in property string answerTitle: "" property string answerUrl: "" property string answerQuery: "" + property string answerSource: "" property bool answerDropNext: false + property bool askDropNext: false property var answerCache: ({}) readonly property var answerStop: [ @@ -1746,19 +1771,38 @@ in } } + // Claude, tried first. The script is the feature gate: with + // no API key it exits immediately and prints nothing, which + // is the same signal as a failed call — so the Wikipedia + // path stays the default with zero configuration. + Process { + id: askProc + stdout: StdioCollector { + onStreamFinished: launcherPanel.takeAsk(text) + } + } + function killAnswer() { if (!answerProc.running) return; answerDropNext = true; answerProc.running = false; } + function killAsk() { + if (!askProc.running) return; + askDropNext = true; + askProc.running = false; + } + function clearAnswer() { answerTimer.stop(); killAnswer(); + killAsk(); answerFact = ""; answerLead = ""; answerTitle = ""; answerUrl = ""; + answerSource = ""; } function runAnswer() { @@ -1773,16 +1817,51 @@ in showAnswer(hit); return; } + // Claude first — it answers the questions Wikipedia + // can't. Silence from it means "no key, unreachable, or + // doesn't know", and Wikipedia takes over. + killAsk(); + askProc.command = [Commands.askFetch, q]; + askProc.running = true; + } + + function startWiki() { killAnswer(); - answerProc.command = [Commands.answerFetch, q]; + answerProc.command = [Commands.answerFetch, answerQuery]; answerProc.running = true; } + function takeAsk(raw) { + if (askDropNext) { askDropNext = false; return; } + const text = raw.trim(); + if (text === "") { + startWiki(); + return; + } + const res = { + title: "", // no article behind it + fact: text.slice(0, 400), + lead: "", + url: "", // nothing to open + source: "Claude" + }; + cacheAnswer(res); + // Typing may have moved on while the call was out. + if (answerQuery !== answerKey(searchInput.text)) return; + showAnswer(res); + } + + function cacheAnswer(res) { + if (Object.keys(answerCache).length > 40) answerCache = ({}); + answerCache[answerQuery] = res; + } + function showAnswer(res) { answerTitle = res.title; answerFact = res.fact; answerLead = res.lead; answerUrl = res.url; + answerSource = res.source; } // Wikipedia hands back a whole intro; the sentence that @@ -1890,10 +1969,10 @@ in fact: picked.fact, lead: picked.lead, url: title === "" ? "" - : "https://en.wikipedia.org/wiki/" + encodeURIComponent(title.replace(/ /g, "_")) + : "https://en.wikipedia.org/wiki/" + encodeURIComponent(title.replace(/ /g, "_")), + source: "Wikipedia" }; - if (Object.keys(answerCache).length > 40) answerCache = ({}); - answerCache[answerQuery] = res; + cacheAnswer(res); // Typing may have moved on while curl was out. if (answerQuery !== answerKey(searchInput.text)) return; showAnswer(res); @@ -2276,8 +2355,11 @@ in anchors.rightMargin: 14 spacing: 4 + // Article title — Wikipedia only; a Claude + // answer has no page behind it. Row { spacing: 8 + visible: launcherPanel.answerTitle !== "" SIcon { anchors.verticalCenter: parent.verticalCenter text: "sparkles" @@ -2311,18 +2393,21 @@ in wrapMode: Text.WordWrap } + // Always say who answered — the two sources + // carry very different trust. SText { - text: "Wikipedia" + text: launcherPanel.answerSource font.pixelSize: 10 color: Theme.base03 } } + // Only Wikipedia answers have somewhere to go. MouseArea { anchors.fill: parent + enabled: launcherPanel.answerUrl !== "" cursorShape: Qt.PointingHandCursor onClicked: { - if (launcherPanel.answerUrl === "") return; Quickshell.execDetached([Commands.xdgOpen, launcherPanel.answerUrl]); launcherPanel.open = false; }