diff --git a/plugins/registry.json b/plugins/registry.json index 46fec1c..6e538e9 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1382,6 +1382,35 @@ ], "lastUpdated": "2026-09-29", "upstream": "https://github.com/vampireJSV/ryoku-plugin-kdeconnect-status" + }, + { + "id": "strata", + "name": "Local LLM Manager", + "version": "0.4.0", + "path": "plugins/strata", + "author": "guyb ", + "upstream": "https://github.com/ryoku-dev/ryostore/tree/main/plugins/strata", + "summary": "Pick a local model and start or stop it from the bar.", + "description": "Pick a local model and start or stop it from the bar. Finds Strata, TabbyAPI and llama.cpp engines and their models, one port per model.", + "tags": [ + "bar-widget" + ], + "accent": "#8f8378", + "surface": "#101010", + "preview": "assets/preview-widget.png", + "screenshots": [ + "assets/preview-widget.png", + "assets/preview-panel.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "c0f9626812f2b8e155247286d14d53cfcbf9982f9897bc2a9134335971b1b923", + "official": false, + "tagline": "Pick a local model and start or stop it from the bar.", + "icon": "", + "hosts": [ + "topbarGlyph" + ], + "lastUpdated": "2026-10-06" } ] } diff --git a/plugins/strata/.gitignore b/plugins/strata/.gitignore new file mode 100644 index 0000000..b8eb3e3 --- /dev/null +++ b/plugins/strata/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.log +*.tmp diff --git a/plugins/strata/AGENTS.md b/plugins/strata/AGENTS.md new file mode 100644 index 0000000..9441a35 --- /dev/null +++ b/plugins/strata/AGENTS.md @@ -0,0 +1,84 @@ +# Agent guide for the strata plugin + +This folder is a Ryoku shell plugin. If you are an AI agent editing it, follow +every rule below. They are the same rules `ryoku plugin validate` enforces, so +work that breaks one will not install. This file is self-contained: you do not +need any other document to work here. + +## The rules + +R1 Place. A plugin is one folder named after its id. Author it under +`$(xdg-user-dir DOCUMENTS)/ryoku-plugins//` (fallback `~/ryoku-plugins//`); +`ryoku plugin new ` creates it there. Never write into +`~/.local/share/ryoku/plugins/` (the store's install root, receipt-owned) or +`~/.config/quickshell/` (the shipped shell). + +R2 Shape. `manifest.json` at the root; `service/Main.qml` (logic, no UI); +`content/Widget.qml` (the one view); optional `content/Panel.qml` (a bar panel); +`README.md`; `LICENSE`; `assets/preview-widget.png` (a real capture); scripts +only under `bin/` with a shebang and the exec bit. Every extra file listed in +manifest `files`. + +R3 Id. Lowercase `[a-z0-9][a-z0-9-]*`, unique, not a built-in widget id. + +R4 Imports. Only `QtQuick*`, `Quickshell*`, `Ryoku.PluginKit`, +`Ryoku.PluginKit.Singletons`, and files inside the plugin folder. Never +`shell.*`, `Ryoku.Ui*` internals, or a relative import that climbs out of the +folder. + +R5 Settings. Declared in `metadata.settings` (types the bar renders: toggle, +choice, multi, int, text); read through `pluginApi.pluginSettings` behind a +default; written only through `pluginApi.saveSetting(key, value)`. Never edit +`shell.json` or `plugins.json` directly. + +R6 Commands. Every external program the plugin runs is either in its own `bin/` +or listed in `dependencies.commands`. No `sudo`, `doas`, `su`. A privileged +action is allowed only through `pkexec`, only on an explicit click, only when +listed in manifest `capabilities.privileged` (exact command strings) and +explained in README. + +R7 Network. Every host the plugin talks to is listed in +`capabilities.network`. No `curl … | sh`, no downloading and running code. + +R8 Files. Write only under `pluginApi.stateDir` +(`$XDG_STATE_HOME/ryoku/plugins/`), `$XDG_CACHE_HOME/ryoku/plugins/`, or +a temp dir. Never touch `~/.ssh`, `/etc`, shell rc files, or another plugin's +folder. + +R9 Shell. No `sh -c` with a string built from settings or program output +(injection). Pass argv arrays. + +R10 Secrets and binaries. No tokens, keys or credentials in the tree; no +compiled binaries (ELF/Mach-O/.so); scripts only. + +R11 Honesty. `official` is never true for a community plugin. `author` is +`Name `. README says what runs, what it reads, what it writes, and every +privileged/network capability. + +## The workflow + +1. `ryoku plugin new ` scaffolds this folder (already done). +2. Edit: `service/Main.qml` for logic, `content/Widget.qml` for the view, and + `content/Panel.qml` for the bar panel. Capture `assets/preview-widget.png`. +3. `ryoku plugin validate ` and fix every blocking finding (warnings are + advisory but read them). +4. `ryoku plugin add --bar --yes` to install it, then verify it on the bar + and under **QS Bar Settings > Community**. +5. `ryoku plugin share ` ONLY when the user asks to publish. Never share a + plugin unless asked. + +## What the host sets + +Every entry point receives, from the host: `pluginApi` (see below), `s` (a scale +factor), `active`, `density`, and `widthBudget`. Read them; never assign them. + +`pluginApi` exposes: + +- `mainInstance`: the live `service/Main.qml` instance every view shares. +- `pluginSettings`: the resolved settings object; read behind a default. +- `pluginDir`: this plugin's folder. +- `stateDir`: `$XDG_STATE_HOME/ryoku/plugins/`; the only place to write. +- `saveSetting(key, value)`: persist one setting. +- `panelOpen` (bool), `openPanel()`, `closePanel()`, `togglePanel()`: the bar + panel controls (present but inert off the bar). The sanctioned widget click is + `onClicked: pluginApi.togglePanel()`. diff --git a/plugins/strata/CLAUDE.md b/plugins/strata/CLAUDE.md new file mode 100644 index 0000000..3c35c32 --- /dev/null +++ b/plugins/strata/CLAUDE.md @@ -0,0 +1,4 @@ +# CLAUDE.md + +Read `AGENTS.md` in this folder before editing anything here. +It holds every rule this plugin must follow and the full authoring workflow. diff --git a/plugins/strata/LICENSE b/plugins/strata/LICENSE new file mode 100644 index 0000000..d98d35e --- /dev/null +++ b/plugins/strata/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 guyb + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/strata/README.md b/plugins/strata/README.md new file mode 100644 index 0000000..256bee5 --- /dev/null +++ b/plugins/strata/README.md @@ -0,0 +1,37 @@ +# Local LLM Manager + +Bar widget for local model servers. It finds every installed engine and its ready models by itself, +runs one model at a time, and gives each model its own port. + +- **Glyph**: a chip icon (lit while a model runs) and the selected model's name. Click to open the panel. +- **Panel**: models grouped by engine, each with its port; the selected model's address; START / STOP. + +## Engines and models (auto-detected) + +| Engine | Found when | Models | Started as | +|---|---|---|---| +| Strata | `/serve/server.py` exists | every `strata-.json` in it (port from its `"port"`) | `/.venv/bin/python` (or `python3`) `serve/server.py --engine strata --config --port --lazy --idle-unload 900` | +| TabbyAPI | `/run.sh` exists | every finished folder in its `models/` (EXL3) | `run.sh --port --model-name ` | +| llama.cpp | `llama-server` is on the PATH | every `*.gguf` in the GGUF folders (first shard; no mmproj/imatrix) | `llama-server -m --host 127.0.0.1 --port -c 16384` | + +A model with no fixed port gets the next free one from 8081 (skipping ports in use) the first time it +is seen, kept in `ports` under the state dir so it never changes. + +## Settings (QS Bar Settings > Community > Local LLM Manager, group "Paths") + +| Setting | Default | +|---|---| +| Strata folder | `~/Strata` | +| TabbyAPI folder | `~/tabbyAPI` | +| GGUF folders (space-separated) | `~/models` | + +## What it runs, reads, writes + +- `bin/strata-models list` every 5 s (reads the folders above, `ss -ltn` when assigning a port) and + `systemctl --user is-active strata-model.service`. +- On click: `use` writes `current`; `start` stops `strata-model.service` if it runs, then launches the + selected model as that transient user unit with `systemd-run --user` (command lines above); + `stop` stops it. Model servers you started by hand are left alone. +- Nothing to install: the unit is transient, nothing is enabled, nothing starts at login. +- Writes only under `$XDG_STATE_HOME/ryoku/plugins/strata/`. No privileged commands. Network: none + itself; it only shows the local servers' `127.0.0.1` address. diff --git a/plugins/strata/assets/preview-panel.png b/plugins/strata/assets/preview-panel.png new file mode 100644 index 0000000..afc8399 Binary files /dev/null and b/plugins/strata/assets/preview-panel.png differ diff --git a/plugins/strata/assets/preview-widget.png b/plugins/strata/assets/preview-widget.png new file mode 100644 index 0000000..6d92934 Binary files /dev/null and b/plugins/strata/assets/preview-widget.png differ diff --git a/plugins/strata/bin/strata-models b/plugins/strata/bin/strata-models new file mode 100755 index 0000000..7ef0795 --- /dev/null +++ b/plugins/strata/bin/strata-models @@ -0,0 +1,113 @@ +#!/bin/sh +# Finds every installed engine and its ready models; one model runs at a time, as the transient +# user unit strata-model.service (systemd-run; nothing to install, nothing starts at login). +# +# strata-models list one "keyenginelabelportunitpath" line per model +# strata-models current print the selected model key ("engine:name"), or nothing +# strata-models use select a model; restarts the server if one is running +# strata-models start start the selected model (stops the previous one) +# strata-models run select a model and start it +# strata-models stop stop the running model +# +# Engines: Strata ($STRATA_DIR, default ~/Strata), TabbyAPI ($TABBY_DIR, default ~/tabbyAPI), +# llama.cpp (llama-server on the PATH; *.gguf in $GGUF_DIRS, space-separated, default ~/models). +home() { case "$1" in "~") echo "$HOME" ;; "~/"*) echo "$HOME/${1#\~/}" ;; *) echo "$1" ;; esac; } +strata=$(home "${STRATA_DIR:-$HOME/Strata}") +tabby=$(home "${TABBY_DIR:-$HOME/tabbyAPI}") +gguf_dirs=$(for g in ${GGUF_DIRS:-$HOME/models}; do home "$g"; done) +unit=strata-model.service + +# a stable port per model, kept in /ports ("key port"); skips ports already taken or in use +port_for() { # key state [fixed] + [ -n "$3" ] && { echo "$3"; return; } + p=$(awk -v k="$1" '$1 == k { print $2 }' "$2/ports" 2>/dev/null) + [ -n "$p" ] && { echo "$p"; return; } + used=$( { cat "$strata"/strata-*.json 2>/dev/null | sed -n 's/.*"port": *\([0-9]*\).*/\1/p' + awk '{ print $2 }' "$2/ports" 2>/dev/null + ss -Hltn 2>/dev/null | awk '{ n = split($4, a, ":"); print a[n] }'; } | sort -u) + p=8081 + while echo "$used" | grep -qx "$p"; do p=$((p + 1)); done + mkdir -p "$2" && echo "$1 $p" >> "$2/ports" + echo "$p" +} + +row() { # key engine label port path + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$unit" "$5" +} + +list() { # state + if [ -f "$strata/serve/server.py" ]; then + for f in "$strata"/strata-*.json; do + [ -e "$f" ] || continue + n=$(basename "$f" .json | sed 's/^strata-//') + fixed=$(sed -n 's/.*"port": *\([0-9]*\).*/\1/p' "$f" | head -n 1) + row "strata:$n" Strata "$n" "$(port_for "strata:$n" "$1" "${fixed:-8081}")" "$f" + done + fi + if [ -x "$tabby/run.sh" ]; then + for d in "$tabby"/models/*/; do + d=${d%/} + # a finished download: config and shards present, none still partial in hf's cache + [ -f "$d/config.json" ] && ls "$d"/*.safetensors >/dev/null 2>&1 || continue + ls "$d"/.cache/huggingface/download/*.incomplete >/dev/null 2>&1 && continue + n=$(basename "$d") + row "tabby:$n" TabbyAPI "$(echo "$n" | sed 's/[-_.]exl3.*//I')" "$(port_for "tabby:$n" "$1")" "$d" + done + fi + if command -v llama-server >/dev/null; then + for g in $gguf_dirs; do + for f in "$g"/*.gguf; do + [ -e "$f" ] || continue + n=$(basename "$f" .gguf) + case "$n" in *mmproj*|*imatrix*) continue ;; *-0000[2-9]-of-*|*-000[1-9][0-9]-of-*) continue ;; esac + row "llama:$n" llama.cpp "$(echo "$n" | sed 's/\.i1-.*//; s/-00001-of-.*//')" "$(port_for "llama:$n" "$1")" "$f" + done + done + fi + return 0 +} + +current() { # state + k=$(cat "$1/current" 2>/dev/null) || return 0 + case "$k" in *:*) echo "$k" ;; ?*) echo "strata:$k" ;; esac # older files hold a bare Strata name +} +lookup() { list "$2" | awk -F '\t' -v k="$1" '$1 == k'; } + +start() { # state + k=$(current "$1") + [ -n "$k" ] || { echo "no model selected" >&2; exit 2; } + r=$(lookup "$k" "$1") + [ -n "$r" ] || { echo "model not ready" >&2; exit 2; } + port=$(echo "$r" | cut -f4); path=$(echo "$r" | cut -f6) + systemctl --user stop "$unit" 2>/dev/null + systemctl --user reset-failed "$unit" 2>/dev/null + set -- systemd-run --user --unit="$unit" --collect --property=Restart=on-failure + case "$k" in + strata:*) + py="$strata/.venv/bin/python"; [ -x "$py" ] || py=python3 + "$@" --working-directory="$strata" -- "$py" "$strata/serve/server.py" --engine strata \ + --config "$path" --port "$port" --lazy --idle-unload 900 ;; + tabby:*) + "$@" --working-directory="$tabby" -- "$tabby/run.sh" --port "$port" --model-name "$(basename "$path")" ;; + llama:*) + "$@" --working-directory="$HOME" -- llama-server -m "$path" --host 127.0.0.1 --port "$port" -c 16384 ;; + esac +} + +case "$1" in +list) list "$2" ;; +current) current "$2" ;; +use) + case "$2" in ''|*[!A-Za-z0-9._:-]*) echo "bad model key" >&2; exit 2 ;; esac + [ -n "$(lookup "$2" "$3")" ] || { echo "no such model" >&2; exit 2; } + mkdir -p "$3" && echo "$2" > "$3/current" + if systemctl --user is-active --quiet "$unit"; then start "$3"; fi ;; +start) start "$2" ;; +run) + case "$2" in ''|*[!A-Za-z0-9._:-]*) echo "bad model key" >&2; exit 2 ;; esac + [ -n "$(lookup "$2" "$3")" ] || { echo "no such model" >&2; exit 2; } + mkdir -p "$3" && echo "$2" > "$3/current" + start "$3" ;; +stop) systemctl --user stop "$unit" 2>/dev/null ;; +*) echo "usage: $0 list|current|use|start|run|stop (see the header)" >&2; exit 2 ;; +esac diff --git a/plugins/strata/content/Panel.qml b/plugins/strata/content/Panel.qml new file mode 100644 index 0000000..2c02db1 --- /dev/null +++ b/plugins/strata/content/Panel.qml @@ -0,0 +1,346 @@ +import QtQuick +import Ryoku.PluginKit +import Ryoku.PluginKit.Singletons + +// Panel: models grouped by engine, each with its own start/stop; the selected model's address and +// a start/stop for it at the bottom. Starting any model stops the others (one at a time). +Item { + id: root + + property var pluginApi + property string density: "full" + property real s: 1 + property real widthBudget: 360 + property bool active: false + + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property bool on: service ? service.running : false + readonly property bool busy: service ? service.busy : false + readonly property string st: service ? service.state : "unknown" + readonly property var sel: service ? service.selected : null + + implicitWidth: root.widthBudget + implicitHeight: col.implicitHeight + 32 * root.s + + onActiveChanged: if (active && service) service.refresh() + + // a status dot: lit while running, pulsing while starting/stopping, hollow when idle + component Dot: Rectangle { + id: dot + property bool lit: false + property bool pulse: false + width: 7 * root.s + height: 7 * root.s + radius: width / 2 + color: lit ? Theme.accent : "transparent" + border.width: lit ? 0 : 1.5 * root.s + border.color: Theme.faint + SequentialAnimation on opacity { + running: dot.pulse + loops: Animation.Infinite + onRunningChanged: if (!running) dot.opacity = 1 + NumberAnimation { to: 0.25; duration: Motion.pulse } + NumberAnimation { to: 1; duration: Motion.pulse } + } + } + + Column { + id: col + x: 16 * root.s + y: 16 * root.s + width: root.width - 32 * root.s + spacing: 14 * root.s + + // header: title, the selected model's address, overall state + Item { + width: col.width + height: Math.max(head.implicitHeight, pill.height) + + Row { + id: head + spacing: 10 * root.s + anchors.verticalCenter: parent.verticalCenter + + GlyphIcon { + anchors.verticalCenter: parent.verticalCenter + width: 22 * root.s + height: 22 * root.s + name: "cpu" + color: root.on ? Theme.accent : Theme.iconDim + Behavior on color { ColorAnimation { duration: Motion.standard } } + } + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 1 * root.s + Text { + text: "Local models" + color: Theme.bright + font.family: Theme.display + font.pixelSize: 18 * root.s + } + Text { + text: root.sel ? root.sel.engine + " · 127.0.0.1:" + root.sel.port : "no model selected" + color: Theme.faint + font.family: Theme.mono + font.pixelSize: 10.5 * root.s + } + } + } + + Rectangle { + id: pill + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: pillRow.implicitWidth + 16 * root.s + height: pillRow.implicitHeight + 8 * root.s + radius: height / 2 + color: root.on ? Theme.threadBg : "transparent" + border.width: 1 + border.color: root.on ? Theme.accent : Theme.border + + Row { + id: pillRow + anchors.centerIn: parent + spacing: 6 * root.s + Dot { + anchors.verticalCenter: parent.verticalCenter + width: 6 * root.s + height: 6 * root.s + lit: root.on + pulse: root.busy + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.st === "activating" ? "starting" + : root.st === "deactivating" ? "stopping" + : root.on ? "running" : root.st === "failed" ? "failed" : "idle" + color: root.on ? Theme.accent : root.st === "failed" ? Theme.verm : Theme.dim + font.family: Theme.mono + font.pixelSize: 10.5 * root.s + } + } + } + } + + Rectangle { width: col.width; height: 1; color: Theme.hair } + + // one group per detected engine + Column { + width: col.width + spacing: 14 * root.s + + Repeater { + model: root.service ? root.service.engines : [] + delegate: Column { + id: group + required property string modelData + readonly property var rows: root.service ? root.service.models.filter(m => m.engine === modelData) : [] + readonly property string est: root.service ? root.service.engineState(modelData) : "unknown" + width: col.width + spacing: 6 * root.s + + Item { + width: group.width + height: Math.max(eyebrow.implicitHeight, meta.implicitHeight) + MicroLabel { + id: eyebrow + anchors.verticalCenter: parent.verticalCenter + label: group.modelData + s: root.s + } + Text { + id: meta + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: (group.est === "active" ? "running · " : group.est === "failed" ? "failed · " : "") + + group.rows.length + (group.rows.length === 1 ? " model" : " models") + color: group.est === "active" ? Theme.accent : group.est === "failed" ? Theme.verm : Theme.faint + font.family: Theme.mono + font.pixelSize: 10 * root.s + } + } + + Repeater { + model: group.rows + delegate: Rectangle { + id: rowItem + required property var modelData + readonly property bool sel: root.service && root.service.current === modelData.key + readonly property bool live: root.service ? root.service.isRunning(modelData.key) : false + width: group.width + height: 44 * root.s + radius: Motion.rSmall * root.s + color: sel ? Theme.threadBg : (pickArea.containsMouse ? Theme.sheen : "transparent") + border.width: 1 + border.color: live ? Theme.accent : sel ? Theme.lineStrong : Theme.hair + opacity: root.busy && !sel ? 0.55 : 1 + Behavior on color { ColorAnimation { duration: Motion.fast } } + Behavior on border.color { ColorAnimation { duration: Motion.fast } } + + MouseArea { + id: pickArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !root.busy + onClicked: if (root.service) root.service.pick(rowItem.modelData.key) + } + + Dot { + id: rowDot + x: 14 * root.s + anchors.verticalCenter: parent.verticalCenter + lit: rowItem.live + pulse: rowItem.sel && root.busy + border.color: rowItem.sel ? Theme.accent : Theme.faint + } + + Column { + anchors.left: rowDot.right + anchors.leftMargin: 12 * root.s + anchors.right: go.left + anchors.rightMargin: 10 * root.s + anchors.verticalCenter: parent.verticalCenter + spacing: 2 * root.s + Text { + width: parent.width + text: rowItem.modelData.label + color: rowItem.sel ? Theme.bright : Theme.dim + font.family: Theme.mono + font.pixelSize: 12.5 * root.s + elide: Text.ElideRight + } + Text { + width: parent.width + text: ":" + rowItem.modelData.port + (rowItem.live ? " · running" : "") + color: rowItem.live ? Theme.accent : Theme.faint + font.family: Theme.mono + font.pixelSize: 10 * root.s + elide: Text.ElideRight + } + } + + // this model's own start / stop + Rectangle { + id: go + anchors.right: parent.right + anchors.rightMargin: 9 * root.s + anchors.verticalCenter: parent.verticalCenter + width: 28 * root.s + height: 28 * root.s + radius: width / 2 + color: goArea.pressed ? Theme.vermDeep + : goArea.containsMouse ? (rowItem.live ? Theme.sheen : Theme.accent) + : "transparent" + border.width: 1 + border.color: rowItem.live || goArea.containsMouse ? Theme.accent : Theme.border + Behavior on color { ColorAnimation { duration: Motion.fast } } + + GlyphIcon { + anchors.centerIn: parent + width: 12 * root.s + height: 12 * root.s + name: rowItem.live ? "stop" : "play" + color: goArea.containsMouse && !rowItem.live ? Theme.cardBot + : rowItem.live ? Theme.accent : Theme.dim + } + MouseArea { + id: goArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !root.busy + onClicked: if (root.service) root.service.toggleModel(rowItem.modelData.key) + } + } + } + } + } + } + + Text { + visible: root.service && root.service.models.length === 0 + text: "No models found yet." + color: Theme.faint + font.family: Theme.font + font.pixelSize: 12 * root.s + } + } + + Rectangle { width: col.width; height: 1; color: Theme.hair } + + // the selected model's OpenAI-style address (selectable, to copy) + Column { + width: col.width + spacing: 4 * root.s + visible: root.sel !== null + MicroLabel { label: "Address"; s: root.s } + TextInput { + width: parent.width + readOnly: true + selectByMouse: true + text: root.sel ? "http://127.0.0.1:" + root.sel.port + "/v1" : "" + color: root.on ? Theme.bright : Theme.dim + selectionColor: Theme.accent + selectedTextColor: Theme.cardBot + font.family: Theme.mono + font.pixelSize: 12 * root.s + } + } + + // start / stop the selected model + Rectangle { + id: action + width: col.width + height: 38 * root.s + radius: Motion.rSmall * root.s + color: root.on ? (area.containsMouse ? Theme.sheen : "transparent") + : (area.pressed ? Theme.vermDeep : area.containsMouse ? Theme.vermLit : Theme.accent) + border.width: root.on ? 1 : 0 + border.color: Theme.lineStrong + opacity: root.busy || !root.sel ? 0.6 : 1 + Behavior on color { ColorAnimation { duration: Motion.fast } } + + Row { + anchors.centerIn: parent + spacing: 8 * root.s + GlyphIcon { + anchors.verticalCenter: parent.verticalCenter + width: 14 * root.s + height: 14 * root.s + name: root.on ? "stop" : "play" + color: root.on ? Theme.bright : Theme.cardBot + visible: !root.busy + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.st === "activating" ? "STARTING…" + : root.st === "deactivating" ? "STOPPING…" + : root.on ? "STOP SERVER" : "START SELECTED" + color: root.on ? Theme.bright : Theme.cardBot + font.family: Theme.mono + font.pixelSize: 11.5 * root.s + font.weight: Font.DemiBold + font.letterSpacing: 1.6 * root.s + } + } + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !root.busy && root.sel !== null + onClicked: if (root.service) root.service.toggle() + } + } + + Text { + width: col.width + wrapMode: Text.WordWrap + text: "One model runs at a time: starting one stops the others." + color: Theme.faint + font.family: Theme.font + font.pixelSize: 11 * root.s + } + } +} diff --git a/plugins/strata/content/Widget.qml b/plugins/strata/content/Widget.qml new file mode 100644 index 0000000..6a3cbd7 --- /dev/null +++ b/plugins/strata/content/Widget.qml @@ -0,0 +1,78 @@ +import QtQuick +import Ryoku.PluginKit +import Ryoku.PluginKit.Singletons + +// Bar glyph: chip icon with a status dot, then the selected model's name. +Item { + id: root + + property var pluginApi + property var screen + property bool active: false + property string density: "glyph" + property real s: 1 + property real widthBudget: 0 + + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property bool on: service ? service.running : false + readonly property bool busy: service ? service.busy : false + + implicitWidth: row.implicitWidth + implicitHeight: Math.max(row.implicitHeight, 18 * root.s) + + Row { + id: row + anchors.verticalCenter: parent.verticalCenter + spacing: 6 * root.s + + Item { + anchors.verticalCenter: parent.verticalCenter + width: 15 * root.s + height: 15 * root.s + + GlyphIcon { + anchors.fill: parent + name: "cpu" + color: root.on ? Theme.accent : Theme.iconDim + Behavior on color { ColorAnimation { duration: Motion.standard } } + } + // status dot, bottom-right of the icon + Rectangle { + id: dot + width: 6 * root.s + height: 6 * root.s + radius: width / 2 + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: -1 * root.s + color: root.on ? Theme.accent : Theme.ghost + border.width: 1 + border.color: Theme.cardBot + SequentialAnimation on opacity { + running: root.busy + loops: Animation.Infinite + onRunningChanged: if (!running) dot.opacity = 1 + NumberAnimation { to: 0.25; duration: Motion.pulse } + NumberAnimation { to: 1; duration: Motion.pulse } + } + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.service && root.service.selected ? root.service.selected.label : "models" + color: root.on ? Theme.bright : Theme.dim + font.family: Theme.mono + font.pixelSize: 12 * root.s + elide: Text.ElideRight + width: root.widthBudget > 0 ? Math.min(implicitWidth, root.widthBudget - 21 * root.s) : implicitWidth + Behavior on color { ColorAnimation { duration: Motion.standard } } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (root.pluginApi) root.pluginApi.togglePanel() + } +} diff --git a/plugins/strata/manifest.json b/plugins/strata/manifest.json new file mode 100644 index 0000000..a415002 --- /dev/null +++ b/plugins/strata/manifest.json @@ -0,0 +1,72 @@ +{ + "id": "strata", + "name": "Local LLM Manager", + "version": "0.4.0", + "author": "guyb ", + "description": "Pick a local model and start or stop it from the bar. Finds Strata, TabbyAPI and llama.cpp engines and their models, one port per model.", + "license": "MIT", + "official": false, + "hosts": [ + "topbarGlyph" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml", + "panel": "content/Panel.qml" + }, + "panel": { + "width": 360 + }, + "files": [ + "bin/strata-models", + "assets/preview-widget.png" + ], + "capabilities": { + "densities": [ + "glyph", + "compact" + ], + "network": [ + "127.0.0.1" + ] + }, + "defaults": { + "host": "topbarGlyph", + "label": "Local LLM Manager" + }, + "dependencies": { + "commands": [ + "systemctl", + "systemd-run", + "ss" + ] + }, + "metadata": { + "settings": [ + { + "key": "strataDir", + "type": "text", + "label": "Strata folder", + "group": "Paths", + "default": "~/Strata", + "placeholder": "~/Strata" + }, + { + "key": "tabbyDir", + "type": "text", + "label": "TabbyAPI folder", + "group": "Paths", + "default": "~/tabbyAPI", + "placeholder": "~/tabbyAPI" + }, + { + "key": "ggufDirs", + "type": "text", + "label": "GGUF folders (space-separated)", + "group": "Paths", + "default": "~/models", + "placeholder": "~/models ~/llm/gguf" + } + ] + } +} diff --git a/plugins/strata/product-manifest.json b/plugins/strata/product-manifest.json new file mode 100644 index 0000000..2e8a04c --- /dev/null +++ b/plugins/strata/product-manifest.json @@ -0,0 +1,105 @@ +{ + "schema": 1, + "id": "strata", + "category": "plugins", + "version": "0.4.0", + "destination": "ryoku/plugins/strata", + "files": [ + { + "source": ".gitignore", + "destination": ".gitignore", + "sha256": "379dfa9273952c08fa44042de676ed4addc26a7c4d4c90235d6ff2c73dffb810", + "mode": "0644", + "size": 22, + "install": true + }, + { + "source": "AGENTS.md", + "destination": "AGENTS.md", + "sha256": "0e08c37084d75f2911aa191bc73f3acf9744a9cd52604c556f96608bbf7a6894", + "mode": "0644", + "size": 3955, + "install": true + }, + { + "source": "CLAUDE.md", + "destination": "CLAUDE.md", + "sha256": "09ffb8ebdb8adf50bf4bde26dfe2109b4af3a4a8cc3f30cc536be42bb912c033", + "mode": "0644", + "size": 152, + "install": true + }, + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "06181c521ca2a124453c1db837e83a0c076ccb465b6df225d8ad2376c5bfb20f", + "mode": "0644", + "size": 1061, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "56ad9ad18147ed3dbb2774f6ba0d3e0432a1899ce4534183c66595cb22ac2f88", + "mode": "0644", + "size": 2170, + "install": false + }, + { + "source": "assets/preview-panel.png", + "destination": "assets/preview-panel.png", + "sha256": "b2ad3f921d3f9cb782b8e0130d649ba5414e99efadb03e4360edfabfaa6c9b12", + "mode": "0644", + "size": 144462, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "3ada94879c01839c1a4baf29b1b51a1e5ec696a7f674b28d0c85e893ce513f08", + "mode": "0644", + "size": 5075, + "install": false + }, + { + "source": "bin/strata-models", + "destination": "bin/strata-models", + "sha256": "c0e9dc5417e424ce05dca1eb61f93203ee737b27df56371ef87e426a484c2d9e", + "mode": "0755", + "size": 5313, + "install": true + }, + { + "source": "content/Panel.qml", + "destination": "content/Panel.qml", + "sha256": "37a7dffed499a0c95392d201a1d15f2ab7f0bf8b89b91297f0baab209c2fa927", + "mode": "0644", + "size": 15317, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "6f72d1d6e42efef0c5d38415278d3538d5eabebb3c853a7f5ba50e151e1dca62", + "mode": "0644", + "size": 2718, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "bfa159a4299775ec7bf90851a9b2f0cba75cdeb98356103f2871e98e41c5dbec", + "mode": "0644", + "size": 1549, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "e846230a44d906bd4b434377fbee09272693c9041d2baefdbd3c25447151866a", + "mode": "0644", + "size": 4538, + "install": true + } + ] +} diff --git a/plugins/strata/service/Main.qml b/plugins/strata/service/Main.qml new file mode 100644 index 0000000..a0aec3f --- /dev/null +++ b/plugins/strata/service/Main.qml @@ -0,0 +1,113 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io + +// Finds every installed engine (Strata, TabbyAPI, llama.cpp) and its ready models through +// bin/strata-models, polls their user units, and starts/stops the selected model (one at a time). +Item { + id: svc + + property var pluginApi + readonly property string bin: pluginApi ? pluginApi.pluginDir + "/bin/strata-models" : "" + readonly property string stateDir: pluginApi ? pluginApi.stateDir : "" + readonly property var settings: pluginApi && pluginApi.pluginSettings ? pluginApi.pluginSettings : ({}) + // where the script looks for engines and models (the manifest's Paths settings) + readonly property var env: ({ + STRATA_DIR: settings.strataDir || "~/Strata", + TABBY_DIR: settings.tabbyDir || "~/tabbyAPI", + GGUF_DIRS: settings.ggufDirs || "~/models" + }) + onEnvChanged: refresh() + + property var models: [] // [{ key, engine, label, port, unit }] + property string current: "" // selected model key, "engine:name" + property string state: "unknown" // the "most active" systemctl is-active result across units + property var unitStates: ({}) // unit -> systemctl is-active result + readonly property var engines: models.reduce((a, m) => a.includes(m.engine) ? a : a.concat([m.engine]), []) + readonly property var units: ["strata-model.service"] // the one transient unit the script runs + readonly property var selected: models.find(m => m.key === current) || null + readonly property bool running: state === "active" + readonly property bool busy: state === "activating" || state === "deactivating" || ctl.running + + // the running model: the selected one, while the unit is up (starts always go through it) + function isRunning(key) { + const m = svc.models.find(x => x.key === key); + return !!m && key === svc.current && svc.unitStates[m.unit] === "active"; + } + function engineState(engine) { + return svc.selected && svc.selected.engine === engine + ? (svc.unitStates[svc.selected.unit] || "unknown") : "inactive"; + } + + function refresh() { + if (!poll.running) poll.running = true; + if (bin && !list.running) list.running = true; + if (bin && !cur.running) cur.running = true; + } + function run(cmd, next) { + if (ctl.running) return; + ctl.command = cmd; + svc.state = next; + ctl.running = true; + } + function toggle() { + svc.run(svc.running ? [svc.bin, "stop"] : [svc.bin, "start", svc.stateDir], + svc.running ? "deactivating" : "activating"); + } + // start this model now (stops whatever else runs), or stop it if it is the one running + function toggleModel(key) { + if (svc.isRunning(key)) return svc.run([svc.bin, "stop"], "deactivating"); + svc.current = key; + svc.run([svc.bin, "run", key, svc.stateDir], "activating"); + } + function pick(key) { + if (key === svc.current) return; + svc.current = key; + svc.run([svc.bin, "use", key, svc.stateDir], svc.running ? "activating" : svc.state); + } + + Process { + id: poll + command: ["systemctl", "--user", "is-active"].concat(svc.units) + stdout: StdioCollector { + onStreamFinished: { + const st = this.text.split("\n"); + const map = {}; + svc.units.forEach((u, i) => map[u] = st[i] || "unknown"); + svc.unitStates = map; + svc.state = ["active", "activating", "deactivating", "failed", "inactive"] + .find(k => st.includes(k)) || "unknown"; + } + } + } + Process { + id: list + command: [svc.bin, "list", svc.stateDir] + environment: svc.env + stdout: StdioCollector { + onStreamFinished: svc.models = this.text.split("\n").filter(l => l.length).map(l => { + const f = l.split("\t"); + return { key: f[0], engine: f[1], label: f[2], port: f[3], unit: f[4] }; + }) + } + } + Process { + id: cur + command: [svc.bin, "current", svc.stateDir] + environment: svc.env + stdout: StdioCollector { onStreamFinished: svc.current = this.text.trim() } + } + Process { + id: ctl + environment: svc.env + onExited: svc.refresh() + } + Timer { + interval: 5000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: svc.refresh() + } +}