Merge pull request 'feat(homepage): Audiobookshelf's own mark, and frank-tts in the Model Information block' (#150) from feat/homepage-abs-icon-and-tts-card into main

This commit was merged in pull request #150.
This commit is contained in:
dank committed 2026-11-04 13:50:50 -08:00
commit b3eb9c6314
8 files changed
+60 -25

No files matched your search

@@ -317,7 +317,7 @@ flowchart TD
**User services** (root page), in section order: **User services** (root page), in section order:
- **Web Apps** — SearXNG, Gitea, Copyparty, Marreta. - **Web Apps** — SearXNG, Gitea, Copyparty, Marreta.
- **Media Library Apps** — Plex, Jellyfin, Audiobookshelf (moved up from the admin page 2026-11-03, once Authentik SSO made it a household app). - **Media Library Apps** — Plex, Jellyfin, Audiobookshelf (moved up from the admin page 2026-11-03, once Authentik SSO made it a household app).
- **AI** — OpenWebUI, and the three LLM fleet cards (frank-dragoon, frank-archon, frank-zealot — status + docs link, no Open link); their model table renders under this heading. - **AI** — OpenWebUI, the three LLM fleet cards (frank-dragoon, frank-archon, frank-zealot) and `frank-tts`, the cloned-voice endpoint (moved up from the admin page 2026-11-04 so the household sees whether the voice door is up). All four are status + docs link, no Open link; the fused **Model Information** panel renders under this heading.
- **Work in progress** — Calibre-Web, RomM, Seerr. WIP badge, no Open link, no household account yet: the ask-Frank door. See the docs page's *Three doors that are not open to the household yet*. - **Work in progress** — Calibre-Web, RomM, Seerr. WIP badge, no Open link, no household account yet: the ask-Frank door. See the docs page's *Three doors that are not open to the household yet*.
**Admin services** (`/admin/`), in section order: **Admin services** (`/admin/`), in section order:
@@ -443,6 +443,13 @@ agent knowledge base (`~/.pi/agent/docs/caddy/caddyfile.md`).
- `method` — `tcp` or `http`. The probe type for this service (Q10). - `method` — `tcp` or `http`. The probe type for this service (Q10).
- `wip` — `false`, or `"open"` / `"closed"` / `"down"`. When set, the badge shows - `wip` — `false`, or `"open"` / `"closed"` / `"down"`. When set, the badge shows
**WIP: OPEN** / **WIP: CLOSED** / **WIP: DOWN** and the checker skips the probe. **WIP: OPEN** / **WIP: CLOSED** / **WIP: DOWN** and the checker skips the probe.
- `model` — present only on Model Information cards. Such a card renders in the
fused `renderModelInfo()` panel (`mi-*` styles) instead of the app grid, and its
`description` is unused. `unit` is the StarCraft identity: art
`/img/unit-<unit>.jpg`, accent `.mi-<unit>`, voice lines from `/sfx/`. A card
with no `unit` is a tool (today: `frank-tts`) — art falls back to the card's own
`art`, the accent is `.mi-tool`, and a `tagline` with no `sfx` renders as plain
text rather than a play button.
**A card must be probeable from the monitor's host, or it must be `wip`.** The **A card must be probeable from the monitor's host, or it must be `wip`.** The
checker runs on Dankclaw, so its reach is: that host's own loopback, its own checker runs on Dankclaw, so its reach is: that host's own loopback, its own
+5 -3
View File
@@ -79,15 +79,17 @@ function voiceBtn(text, files) {
function miCard(svc, state) { function miCard(svc, state) {
const m = svc.model; const m = svc.model;
const art = svc.art || '/img/unit-' + m.unit + '.jpg';
const parts = ['<div class="mi-top">' + const parts = ['<div class="mi-top">' +
'<img class="mi-art" src="/img/unit-' + esc(m.unit) + '.jpg" alt="' + esc(m.unit) + '">' + '<img class="mi-art" src="' + esc(art) + '" alt="' + esc(m.unit || svc.name) + '">' +
'<div class="mi-head"><div class="mi-id"><span class="badge ' + state + '">' + BADGE_LABEL[state] + '</span>' + '<div class="mi-head"><div class="mi-id"><span class="badge ' + state + '">' + BADGE_LABEL[state] + '</span>' +
'<span class="mi-name">' + esc(svc.name) + '</span></div>' + '<span class="mi-name">' + esc(svc.name) + '</span></div>' +
'<div class="mi-role">' + esc(m.role) + '</div>' + '<div class="mi-role">' + esc(m.role) + '</div>' +
(m.hf ? '<a class="mi-hf" href="' + esc(m.hf) + (m.hf ? '<a class="mi-hf" href="' + esc(m.hf) +
'" target="_blank" rel="noopener">model on huggingface &#8599;</a>' : '') + '" target="_blank" rel="noopener">model on huggingface &#8599;</a>' : '') +
'</div></div>', '</div></div>',
voiceBtn(m.tagline, m.sfx) (m.sfx && m.sfx.length ? voiceBtn(m.tagline, m.sfx)
: m.tagline ? '<div class="mi-tag">' + esc(m.tagline) + '</div>' : '')
]; ];
if (m.equiv) parts.push('<div class="mi-equiv">' + esc(m.equiv) + '</div>'); if (m.equiv) parts.push('<div class="mi-equiv">' + esc(m.equiv) + '</div>');
if (m.spec) parts.push('<div class="mi-spec">' + esc(m.spec) + '</div>'); if (m.spec) parts.push('<div class="mi-spec">' + esc(m.spec) + '</div>');
@@ -99,7 +101,7 @@ function miCard(svc, state) {
if (m.warn) parts.push('<details class="mi-warn"><summary>&#9888; privacy note</summary>' + if (m.warn) parts.push('<details class="mi-warn"><summary>&#9888; privacy note</summary>' +
'<div class="warn-body">' + esc(m.warn) + '</div></details>'); '<div class="warn-body">' + esc(m.warn) + '</div></details>');
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'mi-card mi-' + esc(m.unit); card.className = 'mi-card mi-' + esc(m.unit || 'tool');
card.innerHTML = parts.join(''); card.innerHTML = parts.join('');
return card; return card;
} }
@@ -0,0 +1 @@
<svg fill="#82612C" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Audiobookshelf</title><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0Zm-.023.402A11.598 11.598 0 0 1 23.575 12a11.598 11.598 0 0 1-11.598 11.598A11.598 11.598 0 0 1 .378 12 11.598 11.598 0 0 1 11.977.402Zm0 1.776a7.093 7.093 0 0 0-7.092 7.093v1.536a6.395 6.395 0 0 0-.439.33.35.35 0 0 0-.126.27v1.84a.36.36 0 0 0 .126.272c.22.182.722.564 1.504.956v.179c0 .483.31.873.694.873.384 0 .694-.392.694-.873v-4.415c0-.483-.31-.873-.694-.873-.369 0-.67.359-.694.812h-.002v-.91a6.027 6.027 0 1 1 12.054.003v.91c-.025-.454-.326-.813-.695-.813-.384 0-.694.391-.694.873v4.415c0 .483.31.873.694.873.384 0 .695-.392.695-.873v-.179a7.964 7.964 0 0 0 1.503-.956.35.35 0 0 0 .126-.272v-1.843a.342.342 0 0 0-.124-.27 5.932 5.932 0 0 0-.438-.329V9.271a7.093 7.093 0 0 0-7.092-7.093zm-3.34 5.548a.84.84 0 0 0-.84.84v9.405c0 .464.376.84.84.84h.866a.84.84 0 0 0 .84-.84V8.566a.84.84 0 0 0-.84-.84Zm2.905 0a.84.84 0 0 0-.84.84v9.405c0 .464.377.84.84.84h.867a.84.84 0 0 0 .84-.84V8.566a.84.84 0 0 0-.84-.84zm2.908 0a.84.84 0 0 0-.84.84v9.405c0 .464.376.84.84.84h.867a.84.84 0 0 0 .84-.84V8.566a.84.84 0 0 0-.84-.84zM8.112 9.983h1.915v.2H8.112Zm2.906 0h1.915v.2h-1.915Zm2.908 0h1.915v.2h-1.915zm-7.58 9.119a.633.633 0 0 0 0 1.265h11.26a.632.632 0 0 0 0-1.265z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><polygon points="48,6 86,27 86,69 48,90 10,69 10,27" fill="#1c1300" stroke="#3fc1c9" stroke-width="3"/><text x="48" y="60" font-family="monospace" font-size="30" font-weight="bold" fill="#3fc1c9" text-anchor="middle">AB</text></svg>

Before

Width:  |  Height:  |  Size: 293 B

+13 -4
View File
@@ -522,7 +522,7 @@
"method": "http", "method": "http",
"wip": false, "wip": false,
"showLink": true, "showLink": true,
"art": "/img/apps/mono-ab.svg" "art": "/img/apps/audiobookshelf.svg"
}, },
{ {
"name": "Postgres", "name": "Postgres",
@@ -552,11 +552,20 @@
}, },
{ {
"name": "frank-tts", "name": "frank-tts",
"category": "admin", "category": "user",
"section": "AI", "section": "AI",
"description": "The fleet's cloned-voice speech endpoint: POST /v1/audio/speech with voice=<name>, audio back in that voice. Published on 7772; the engine behind it (frank-tts-engine) is unpublished on purpose. It is also the harness speech role.", "model": {
"role": "The fleet's voice — it speaks, it does not chat",
"hf": "https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF",
"tagline": "Your library, read aloud in a voice you know.",
"spec": "Qwen3-TTS 1.7 B base talker (Q4_K_M) with its 12 Hz tokenizer, run by qwentts.cpp on the gfx1151 iGPU. The frank-tts door answers POST /v1/audio/speech with voice=<name> and hands back audio in that voice.",
"inputs": ["text"],
"use": "Narration: Open Notebook episodes, the operator harness's spoken output, and anything that should sound like a person instead of a text-to-speech robot.",
"permission": "Ask Frank for a voice — each pack is built from that person's own recording.",
"warn": "Cloned voices. Every pack on this endpoint is a replica of a real person's voice. Audio coming out of it is not that person speaking and must never be passed off as them."
},
"url": "", "url": "",
"docs": "", "docs": "/docs#models",
"probe": "127.0.0.1:7772", "probe": "127.0.0.1:7772",
"method": "http", "method": "http",
"wip": false, "wip": false,
+2
View File
@@ -263,6 +263,7 @@ html[data-bare="1"] .top { border-image: none; }
.mi-zealot { --mi: #4fb4ff; } /* Zealot: Khai's blue blades */ .mi-zealot { --mi: #4fb4ff; } /* Zealot: Khai's blue blades */
.mi-archon { --mi: #a8ecff; } /* Archon: pure psychic light */ .mi-archon { --mi: #a8ecff; } /* Archon: pure psychic light */
.mi-dragoon { --mi: #5fe0d0; } /* Dragoon: phase-crystal teal */ .mi-dragoon { --mi: #5fe0d0; } /* Dragoon: phase-crystal teal */
.mi-tool { --mi: #ffc86b; } /* frank-tts: amber — a tool, not a unit */
.mi-top { display: flex; align-items: center; gap: 10px; } .mi-top { display: flex; align-items: center; gap: 10px; }
.mi-art { .mi-art {
width: 56px; width: 56px;
@@ -294,6 +295,7 @@ html[data-bare="1"] .top { border-image: none; }
.mi-voice:hover { text-shadow: 0 0 8px var(--mi); } .mi-voice:hover { text-shadow: 0 0 8px var(--mi); }
.mi-voice.mi-speaking { animation: mi-pulse 0.7s ease-in-out infinite; } .mi-voice.mi-speaking { animation: mi-pulse 0.7s ease-in-out infinite; }
@keyframes mi-pulse { 50% { text-shadow: 0 0 12px var(--mi); } } @keyframes mi-pulse { 50% { text-shadow: 0 0 12px var(--mi); } }
.mi-tag { color: var(--mi); margin: 4px 0; }
.mi-equiv { color: var(--head); font-style: italic; margin: 4px 0; } .mi-equiv { color: var(--head); font-style: italic; margin: 4px 0; }
.mi-spec { color: var(--fg); margin: 4px 0; } .mi-spec { color: var(--fg); margin: 4px 0; }
.mi-inputs { color: var(--dim); } .mi-inputs { color: var(--dim); }
+10 -5
View File
@@ -981,9 +981,12 @@ DRV
# there is no address the checker could dial, and a card that can never be # there is no address the checker could dial, and a card that can never be
# green is worse than no card. The façade carries the pair's signal — and # green is worse than no card. The façade carries the pair's signal — and
# since the 2026-10-18 deploy that signal is a real probe (`wip: false`), not # since the 2026-10-18 deploy that signal is a real probe (`wip: false`), not
# a placeholder. It stays `showLink: false` and admin-only: an HTTP API has no # a placeholder. It stays `showLink: false`: an HTTP API has no page to open,
# page to open, and the checker counts ANY HTTP response as up, so the card is # and the checker counts ANY HTTP response as up, so the card is honest
# honest without a link and without a `/health` path in the probe. # without a link and without a `/health` path in the probe.
# 2026-11-04: the card moved from admin to the user page's Model Information
# block — the household guide already documents the voices (/docs#models), so
# the family reading about them should also see whether the door is up.
python3 - "$REPO_ROOT" <<'PY' python3 - "$REPO_ROOT" <<'PY'
import json, os, sys import json, os, sys
root = sys.argv[1] root = sys.argv[1]
@@ -996,8 +999,10 @@ assert fts["wip"] is False, \
"the endpoint is deployed; a wip card would hide its real state: " + repr(fts.get("wip")) "the endpoint is deployed; a wip card would hide its real state: " + repr(fts.get("wip"))
assert fts["showLink"] is False and fts["url"] == "", \ assert fts["showLink"] is False and fts["url"] == "", \
"an API endpoint has no UI to link to — it is a status card, not a tile" "an API endpoint has no UI to link to — it is a status card, not a tile"
assert fts["category"] == "admin", \ assert fts["category"] == "user" and fts["section"] == "AI", \
"consumers dial this; it is not an app the user homepage should advertise" "the voice endpoint belongs to the user page's Model Information block: " + repr(fts["category"])
assert isinstance(fts.get("model"), dict) and fts["model"].get("role"), \
"the card renders as a Model Information card, which needs a model object"
assert os.path.exists(f"{root}/shared/homepage{fts['art']}"), f"missing art {fts['art']}" assert os.path.exists(f"{root}/shared/homepage{fts['art']}"), f"missing art {fts['art']}"
PY PY
} }
+21 -11
View File
@@ -76,23 +76,33 @@ PY
@test "the Model Information component is complete: text, art, voices, warnings" { @test "the Model Information component is complete: text, art, voices, warnings" {
# The fused model panel is the family's only description of the fleet. # The fused model panel is the family's only description of the fleet.
# Every model card must carry: equivalence note, supervision level, the # Every card must carry: role, spec, use, an inputs list, and art that
# unit art, at least one playable voice line, and (for uncensored models) # exists. A card with a StarCraft unit identity (a chat model) must also
# the legal warning. The compaction recommendation must name the zealot. # carry the equivalence note, the supervision level, and a playable unit
# voice line whose file ships; a unit-less tool card (frank-tts speaks, it
# does not chat) quotes no unit, so it carries the ask-Frank permission
# note instead. Uncensored models must carry the legal warning. The
# compaction recommendation must name the zealot.
python3 - "$REPO_ROOT/shared/homepage/services.json" "$REPO_ROOT/shared/homepage" <<'PY' python3 - "$REPO_ROOT/shared/homepage/services.json" "$REPO_ROOT/shared/homepage" <<'PY'
import json, os, sys import json, os, sys
services = json.load(open(sys.argv[1])) services = json.load(open(sys.argv[1]))
root = sys.argv[2] root = sys.argv[2]
fleet = [s for s in services if "model" in s] fleet = [s for s in services if "model" in s]
assert {s["name"] for s in fleet} == {"frank-dragoon", "frank-archon", "frank-zealot"}, [s["name"] for s in fleet] assert {s["name"] for s in fleet} == {"frank-dragoon", "frank-archon", "frank-zealot", "frank-tts"}, [s["name"] for s in fleet]
for s in fleet: for s in fleet:
m = s["model"] m = s["model"]
assert m["equiv"].strip(), f"{s['name']} has no equivalence note" assert m["role"].strip() and m["spec"].strip() and m["use"].strip(), f"{s['name']} needs role, spec and use"
assert m["supervision"].strip(), f"{s['name']} has no supervision note" assert m.get("inputs"), f"{s['name']} has no inputs list"
assert m["sfx"], f"{s['name']} has no voice line" art = s.get("art") or "img/unit-%s.jpg" % m["unit"]
assert os.path.isfile(os.path.join(root, "img", "unit-" + m["unit"] + ".jpg")), f"{s['name']} art missing" assert os.path.isfile(os.path.join(root, art.lstrip("/"))), f"{s['name']} art missing: {art}"
for f in m["sfx"] + m.get("voice", {}).get("sfx", []): for f in list(m.get("sfx", [])) + m.get("voice", {}).get("sfx", []):
assert os.path.isfile(os.path.join(root, "sfx", f)), f"{s['name']} voice {f} missing" assert os.path.isfile(os.path.join(root, "sfx", f)), f"{s['name']} voice {f} missing"
if m.get("unit"):
assert m["equiv"].strip(), f"{s['name']} has no equivalence note"
assert m["supervision"].strip(), f"{s['name']} has no supervision note"
assert m.get("sfx"), f"{s['name']} has no voice line"
else:
assert m.get("permission", "").strip(), f"{s['name']} has no permission note"
zealot = next(s for s in fleet if s["name"] == "frank-zealot") zealot = next(s for s in fleet if s["name"] == "frank-zealot")
assert "compaction" in zealot["model"].get("compaction", ""), "zealot must be recommended as compaction model" assert "compaction" in zealot["model"].get("compaction", ""), "zealot must be recommended as compaction model"
assert zealot["model"].get("compaction", "") != "", "compaction advice must be its own visible field" assert zealot["model"].get("compaction", "") != "", "compaction advice must be its own visible field"
@@ -102,8 +112,8 @@ for s in fleet:
uncensored = "ncensored" in m["spec"] or "bliterated" in m["spec"] uncensored = "ncensored" in m["spec"] or "bliterated" in m["spec"]
if uncensored: if uncensored:
assert "legal" in m.get("warn", "").lower(), f"{s['name']} is uncensored but has no legal warning" assert "legal" in m.get("warn", "").lower(), f"{s['name']} is uncensored but has no legal warning"
assert [s["name"] for s in fleet] == ["frank-zealot", "frank-dragoon", "frank-archon"], \ assert [s["name"] for s in fleet] == ["frank-zealot", "frank-dragoon", "frank-archon", "frank-tts"], \
"card render order must be zealot, dragoon, archon: " + str([s["name"] for s in fleet]) "card render order must be zealot, dragoon, archon, then the voice tool: " + str([s["name"] for s in fleet])
w = {s["name"]: s["model"].get("warn", "") for s in fleet} w = {s["name"]: s["model"].get("warn", "") for s in fleet}
assert "legal risk" in w["frank-dragoon"].lower() and "everybody" in w["frank-dragoon"].lower(), \ assert "legal risk" in w["frank-dragoon"].lower() and "everybody" in w["frank-dragoon"].lower(), \
"the abliterated endpoint must carry the common legal warning" "the abliterated endpoint must carry the common legal warning"