quickshell: Claude-backed launcher answers via the mediaserver
Adds qs-ask on the mediaserver — the Anthropic key lives there and nowhere else, so neither desktop holds a credential and there's one place to rotate it. The launcher pipes the question over the same SSH path the server monitor already uses; the query goes over stdin because `ssh host cmd arg` would re-parse arg through the remote shell. Claude answers first, Wikipedia is the fallback. The script is the feature gate: with no key it exits silently, which is the same signal as a failed call or an UNKNOWN reply, so the Wikipedia path stays the default with zero configuration. The card names whichever answered. Haiku 4.5, no thinking and no effort (effort errors on Haiku) — a one-sentence fact needs no reasoning tokens. ~$0.0005 per query. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5c3d84d0c9
commit
958dc9b333
2 changed files with 140 additions and 6 deletions
|
|
@ -60,6 +60,55 @@
|
||||||
esac
|
esac
|
||||||
done
|
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
|
# Basic networking
|
||||||
|
|
|
||||||
|
|
@ -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'
|
'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
|
# cava in raw mode: one newline-terminated frame per tick, bars as
|
||||||
# semicolon-separated 0-100 ints — trivially parsed by SplitParser.
|
# semicolon-separated 0-100 ints — trivially parsed by SplitParser.
|
||||||
# Drives the media card's spectrum ring. Only spawned while the
|
# 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 wlCopy: "${pkgs.wl-clipboard}/bin/wl-copy"
|
||||||
readonly property string fd: "${pkgs.fd}/bin/fd"
|
readonly property string fd: "${pkgs.fd}/bin/fd"
|
||||||
readonly property string answerFetch: "${answerFetchScript}"
|
readonly property string answerFetch: "${answerFetchScript}"
|
||||||
|
readonly property string askFetch: "${askFetchScript}"
|
||||||
readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open"
|
readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open"
|
||||||
}
|
}
|
||||||
'';
|
'';
|
||||||
|
|
@ -1683,7 +1706,9 @@ in
|
||||||
property string answerTitle: ""
|
property string answerTitle: ""
|
||||||
property string answerUrl: ""
|
property string answerUrl: ""
|
||||||
property string answerQuery: ""
|
property string answerQuery: ""
|
||||||
|
property string answerSource: ""
|
||||||
property bool answerDropNext: false
|
property bool answerDropNext: false
|
||||||
|
property bool askDropNext: false
|
||||||
property var answerCache: ({})
|
property var answerCache: ({})
|
||||||
|
|
||||||
readonly property var answerStop: [
|
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() {
|
function killAnswer() {
|
||||||
if (!answerProc.running) return;
|
if (!answerProc.running) return;
|
||||||
answerDropNext = true;
|
answerDropNext = true;
|
||||||
answerProc.running = false;
|
answerProc.running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function killAsk() {
|
||||||
|
if (!askProc.running) return;
|
||||||
|
askDropNext = true;
|
||||||
|
askProc.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
function clearAnswer() {
|
function clearAnswer() {
|
||||||
answerTimer.stop();
|
answerTimer.stop();
|
||||||
killAnswer();
|
killAnswer();
|
||||||
|
killAsk();
|
||||||
answerFact = "";
|
answerFact = "";
|
||||||
answerLead = "";
|
answerLead = "";
|
||||||
answerTitle = "";
|
answerTitle = "";
|
||||||
answerUrl = "";
|
answerUrl = "";
|
||||||
|
answerSource = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function runAnswer() {
|
function runAnswer() {
|
||||||
|
|
@ -1773,16 +1817,51 @@ in
|
||||||
showAnswer(hit);
|
showAnswer(hit);
|
||||||
return;
|
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();
|
killAnswer();
|
||||||
answerProc.command = [Commands.answerFetch, q];
|
answerProc.command = [Commands.answerFetch, answerQuery];
|
||||||
answerProc.running = true;
|
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) {
|
function showAnswer(res) {
|
||||||
answerTitle = res.title;
|
answerTitle = res.title;
|
||||||
answerFact = res.fact;
|
answerFact = res.fact;
|
||||||
answerLead = res.lead;
|
answerLead = res.lead;
|
||||||
answerUrl = res.url;
|
answerUrl = res.url;
|
||||||
|
answerSource = res.source;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wikipedia hands back a whole intro; the sentence that
|
// Wikipedia hands back a whole intro; the sentence that
|
||||||
|
|
@ -1890,10 +1969,10 @@ in
|
||||||
fact: picked.fact,
|
fact: picked.fact,
|
||||||
lead: picked.lead,
|
lead: picked.lead,
|
||||||
url: title === "" ? ""
|
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 = ({});
|
cacheAnswer(res);
|
||||||
answerCache[answerQuery] = res;
|
|
||||||
// Typing may have moved on while curl was out.
|
// Typing may have moved on while curl was out.
|
||||||
if (answerQuery !== answerKey(searchInput.text)) return;
|
if (answerQuery !== answerKey(searchInput.text)) return;
|
||||||
showAnswer(res);
|
showAnswer(res);
|
||||||
|
|
@ -2276,8 +2355,11 @@ in
|
||||||
anchors.rightMargin: 14
|
anchors.rightMargin: 14
|
||||||
spacing: 4
|
spacing: 4
|
||||||
|
|
||||||
|
// Article title — Wikipedia only; a Claude
|
||||||
|
// answer has no page behind it.
|
||||||
Row {
|
Row {
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
visible: launcherPanel.answerTitle !== ""
|
||||||
SIcon {
|
SIcon {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "sparkles"
|
text: "sparkles"
|
||||||
|
|
@ -2311,18 +2393,21 @@ in
|
||||||
wrapMode: Text.WordWrap
|
wrapMode: Text.WordWrap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always say who answered — the two sources
|
||||||
|
// carry very different trust.
|
||||||
SText {
|
SText {
|
||||||
text: "Wikipedia"
|
text: launcherPanel.answerSource
|
||||||
font.pixelSize: 10
|
font.pixelSize: 10
|
||||||
color: Theme.base03
|
color: Theme.base03
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only Wikipedia answers have somewhere to go.
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
enabled: launcherPanel.answerUrl !== ""
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (launcherPanel.answerUrl === "") return;
|
|
||||||
Quickshell.execDetached([Commands.xdgOpen, launcherPanel.answerUrl]);
|
Quickshell.execDetached([Commands.xdgOpen, launcherPanel.answerUrl]);
|
||||||
launcherPanel.open = false;
|
launcherPanel.open = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue