feat(homepage): Audiobookshelf's own mark, and the voice endpoint in the model block
Audiobookshelf carried the improvised "AB" hex monogram — the fallback for apps with no icon. Simple Icons carries its brand mark, so the card now uses /img/apps/audiobookshelf.svg the way Plex and Jellyfin use theirs; the monogram went to .AGENTTRASH. frank-tts (Qwen3-TTS 1.7 B base talker on the gfx1151 iGPU) was an admin-only status tile, so the household read about five cloned voices in /docs and saw no sign of the door that speaks them. It is now a Model Information card on the user page beside the three chat models, carrying the ask-Frank permission note and a privacy note about cloned voices. miCard grew the shape that requires: art falls back to the card's own `art` when a card has no StarCraft `unit`, the accent falls back to `.mi-tool`, and a `tagline` with no `sfx` renders as text instead of a play button that plays nothing. No audio asset ships with the card on purpose: every pack on that endpoint is a replica of a real person's voice, so a sample clip would put a synthetic person's voice in the repo and on the page. Seams: the model-card contract splits unit cards (equivalence, supervision, playable voice line) from tool cards (permission note), and the voice stack's homepage seam now pins category=user with the reason.
This commit is contained in:
1 parent
e6a85176c4
commit
53e4a777b4
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
|
||||||
|
|||||||
@@ -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 ↗</a>' : '') +
|
'" target="_blank" rel="noopener">model on huggingface ↗</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>⚠ privacy note</summary>' +
|
if (m.warn) parts.push('<details class="mi-warn"><summary>⚠ 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 +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 |
@@ -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,
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user