diff --git a/serverAppConfigs-docs/Infrastructure/Homepage.md b/serverAppConfigs-docs/Infrastructure/Homepage.md index 4762a25..c50c720 100644 --- a/serverAppConfigs-docs/Infrastructure/Homepage.md +++ b/serverAppConfigs-docs/Infrastructure/Homepage.md @@ -317,7 +317,7 @@ flowchart TD **User services** (root page), in section order: - **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). -- **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*. **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). - `wip` — `false`, or `"open"` / `"closed"` / `"down"`. When set, the badge shows **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-.jpg`, accent `.mi-`, 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 checker runs on Dankclaw, so its reach is: that host's own loopback, its own diff --git a/shared/homepage/app.js b/shared/homepage/app.js index f0d1ffd..bffc58c 100644 --- a/shared/homepage/app.js +++ b/shared/homepage/app.js @@ -79,15 +79,17 @@ function voiceBtn(text, files) { function miCard(svc, state) { const m = svc.model; + const art = svc.art || '/img/unit-' + m.unit + '.jpg'; const parts = ['
' + - '' + esc(m.unit) + '' + + '' + esc(m.unit || svc.name) + '' + '
' + BADGE_LABEL[state] + '' + '' + esc(svc.name) + '
' + '
' + esc(m.role) + '
' + (m.hf ? 'model on huggingface ↗' : '') + '
', - voiceBtn(m.tagline, m.sfx) + (m.sfx && m.sfx.length ? voiceBtn(m.tagline, m.sfx) + : m.tagline ? '
' + esc(m.tagline) + '
' : '') ]; if (m.equiv) parts.push('
' + esc(m.equiv) + '
'); if (m.spec) parts.push('
' + esc(m.spec) + '
'); @@ -99,7 +101,7 @@ function miCard(svc, state) { if (m.warn) parts.push('
⚠ privacy note' + '
' + esc(m.warn) + '
'); 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(''); return card; } diff --git a/shared/homepage/img/apps/audiobookshelf.svg b/shared/homepage/img/apps/audiobookshelf.svg new file mode 100644 index 0000000..24ac0b9 --- /dev/null +++ b/shared/homepage/img/apps/audiobookshelf.svg @@ -0,0 +1 @@ +Audiobookshelf \ No newline at end of file diff --git a/shared/homepage/img/apps/mono-ab.svg b/shared/homepage/img/apps/mono-ab.svg deleted file mode 100644 index c18bcb4..0000000 --- a/shared/homepage/img/apps/mono-ab.svg +++ /dev/null @@ -1 +0,0 @@ -AB diff --git a/shared/homepage/services.json b/shared/homepage/services.json index e7a4ebc..6a7470b 100644 --- a/shared/homepage/services.json +++ b/shared/homepage/services.json @@ -522,7 +522,7 @@ "method": "http", "wip": false, "showLink": true, - "art": "/img/apps/mono-ab.svg" + "art": "/img/apps/audiobookshelf.svg" }, { "name": "Postgres", @@ -552,11 +552,20 @@ }, { "name": "frank-tts", - "category": "admin", + "category": "user", "section": "AI", - "description": "The fleet's cloned-voice speech endpoint: POST /v1/audio/speech with voice=, 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= 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": "", - "docs": "", + "docs": "/docs#models", "probe": "127.0.0.1:7772", "method": "http", "wip": false, diff --git a/shared/homepage/style.css b/shared/homepage/style.css index 2e73af1..e1426ac 100644 --- a/shared/homepage/style.css +++ b/shared/homepage/style.css @@ -263,6 +263,7 @@ html[data-bare="1"] .top { border-image: none; } .mi-zealot { --mi: #4fb4ff; } /* Zealot: Khai's blue blades */ .mi-archon { --mi: #a8ecff; } /* Archon: pure psychic light */ .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-art { 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.mi-speaking { animation: mi-pulse 0.7s ease-in-out infinite; } @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-spec { color: var(--fg); margin: 4px 0; } .mi-inputs { color: var(--dim); } diff --git a/tests/frank_tts_engine.bats b/tests/frank_tts_engine.bats index 2ad2672..e2edeaa 100755 --- a/tests/frank_tts_engine.bats +++ b/tests/frank_tts_engine.bats @@ -981,9 +981,12 @@ DRV # 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 # 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 - # page to open, and the checker counts ANY HTTP response as up, so the card is - # honest without a link and without a `/health` path in the probe. + # a placeholder. It stays `showLink: false`: an HTTP API has no page to open, + # and the checker counts ANY HTTP response as up, so the card is honest + # 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' import json, os, sys 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")) 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" -assert fts["category"] == "admin", \ - "consumers dial this; it is not an app the user homepage should advertise" +assert fts["category"] == "user" and fts["section"] == "AI", \ + "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']}" PY } diff --git a/tests/homepage_config.bats b/tests/homepage_config.bats index c80ffb2..6e9abff 100644 --- a/tests/homepage_config.bats +++ b/tests/homepage_config.bats @@ -76,23 +76,33 @@ PY @test "the Model Information component is complete: text, art, voices, warnings" { # The fused model panel is the family's only description of the fleet. - # Every model card must carry: equivalence note, supervision level, the - # unit art, at least one playable voice line, and (for uncensored models) - # the legal warning. The compaction recommendation must name the zealot. + # Every card must carry: role, spec, use, an inputs list, and art that + # exists. A card with a StarCraft unit identity (a chat model) must also + # 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' import json, os, sys services = json.load(open(sys.argv[1])) root = sys.argv[2] 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: m = s["model"] - assert m["equiv"].strip(), f"{s['name']} has no equivalence note" - assert m["supervision"].strip(), f"{s['name']} has no supervision note" - assert m["sfx"], f"{s['name']} has no voice line" - assert os.path.isfile(os.path.join(root, "img", "unit-" + m["unit"] + ".jpg")), f"{s['name']} art missing" - for f in m["sfx"] + m.get("voice", {}).get("sfx", []): + assert m["role"].strip() and m["spec"].strip() and m["use"].strip(), f"{s['name']} needs role, spec and use" + assert m.get("inputs"), f"{s['name']} has no inputs list" + art = s.get("art") or "img/unit-%s.jpg" % m["unit"] + assert os.path.isfile(os.path.join(root, art.lstrip("/"))), f"{s['name']} art missing: {art}" + 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" + 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") 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" @@ -102,8 +112,8 @@ for s in fleet: uncensored = "ncensored" in m["spec"] or "bliterated" in m["spec"] if uncensored: 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"], \ - "card render order must be zealot, dragoon, archon: " + str([s["name"] for s in fleet]) +assert [s["name"] for s in fleet] == ["frank-zealot", "frank-dragoon", "frank-archon", "frank-tts"], \ + "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} assert "legal risk" in w["frank-dragoon"].lower() and "everybody" in w["frank-dragoon"].lower(), \ "the abliterated endpoint must carry the common legal warning"