homepage: amber CRT theme, VT323, current-fleet docs + API access guide

- style.css: green phosphor -> amber CRT-AMBIOS palette; docs styles for
  screenshots, code blocks, key warning (full + bare mode)
- index/admin/docs: VT323 from the Google Fonts CDN
- docs.html: rewrite #chat/#models for the live fleet (flash-server v2,
  radiance native FP8, Qwen3.5-9B Q6_K); new #api-access with OpenWebUI
  key walkthrough + Opencode and Pi configs; three OpenWebUI screenshots
- services.json: AI card details match the current fleet
- deploy-remote.sh: ship shared/homepage/img/ to /srv/homepage/
- docs: Homepage.md theme wave + OpenWebUI.md picker access-control law
This commit is contained in:
Frank committed 2026-10-07 23:27:23 -07:00
1 parent 6632d0a2f6
commit cee90eea52
11 files changed
+169 -41

No files matched your search

+3
View File
@@ -322,6 +322,9 @@ deploy_homepage() {
if [[ -d shared/homepage/fonts ]]; then if [[ -d shared/homepage/fonts ]]; then
run_privileged cp -r shared/homepage/fonts /srv/homepage/ run_privileged cp -r shared/homepage/fonts /srv/homepage/
fi fi
if [[ -d shared/homepage/img ]]; then
run_privileged cp -r shared/homepage/img /srv/homepage/
fi
local deployed_at local deployed_at
deployed_at="$(git log -1 --format=%cI)" deployed_at="$(git log -1 --format=%cI)"
printf 'window.DEPLOYED_AT = "%s";\n' "$deployed_at" > /tmp/deployed.js printf 'window.DEPLOYED_AT = "%s";\n' "$deployed_at" > /tmp/deployed.js
+14 -2
View File
@@ -46,6 +46,17 @@ The `model` table carries exactly one decoration row, `frank-secondary`
(context length 200,704 = the per-slot window; description: Qwen3.5-9B (context length 200,704 = the per-slot window; description: Qwen3.5-9B
Q6_K + BF16 vision mmproj). Q6_K + BF16 vision mmproj).
A third gate sits *after* the table, and it is the one that bites family
accounts (2026-10-07): a model row is only visible to a non-admin user when
its **access permission** grants them. A seeded row defaults to admin-only,
so the picker renders "No results found" for every regular user even though
the connection is enabled, `model_ids` is correct, and the endpoint answers
`/v1/models` — and `GET /api/v1/models` returns `[]` for that user while an
admin token returns the model. Fix: set the row's `access_control` to public
(the `access` column on the `model` row) via the admin UI (Admin Panel →
Models → visibility) or the DB. The verification battery below must therefore
run **as a non-admin user**, not only as an admin.
Two gotchas make the wiring non-obvious: Two gotchas make the wiring non-obvious:
- **llama.cpp `/v1/models` is Ollama-shaped.** llama.cpp returns - **llama.cpp `/v1/models` is Ollama-shaped.** llama.cpp returns
@@ -113,8 +124,9 @@ The Ollama connection is disabled (`ollama.enable=false`).
`host.containers.internal`), not a model problem. `host.containers.internal`), not a model problem.
**Durability:** the model table + connections are DB-only (not git-managed); **Durability:** the model table + connections are DB-only (not git-managed);
a fresh data volume loses them. For reproducibility, add an idempotent a fresh data volume loses them — including the public access grant above.
first-start seed script or document the setup in the service doc. For reproducibility, add an idempotent first-start seed script or document
the setup in the service doc.
## API access (auth-gated) ## API access (auth-gated)
@@ -36,6 +36,20 @@ deliberately unprobeable) via the `wip` value (`"open"` / `"closed"`).
Tautulli (Plex statistics, `192.168.0.235:8181`) joined the admin page's Tautulli (Plex statistics, `192.168.0.235:8181`) joined the admin page's
Media Library Apps section. Media Library Apps section.
**Amber CRT theme + API docs (2026-10-07):** the palette moved from green
phosphor to amber CRT-AMBIOS (`--fg #ffb000` on `--bg #0a0600`), and the font
stack now leads with **VT323** loaded from the Google Fonts CDN (the deploy
also ships `shared/homepage/img/` beside the page, so `/img/*` serves the docs
screenshots). The docs page gained a full **API access** walkthrough
(`/docs#api-access`): per-user OpenWebUI API keys (User menu → Settings →
Account → API keys), the base URL `https://ai.franksdank.cloud/api/v1`, and
copy-paste configs for **Opencode** (`~/.config/opencode/opencode.json`) and
**Pi** (`~/.pi/agent/models.json`) pointing at `frank-secondary`. Shipped
together with the OpenWebUI side: `features.api_keys=true` for regular users
and the restored `frank-secondary` model row + public `read` access grant
without which the picker showed "No results found" for every family account
(see [[OpenWebUI]]).
## Design decision — 2026-08-22 ## Design decision — 2026-08-22
The live up/down check runs server-side. A checker on Dankclaw probes each backend and writes a same-origin `status.json`. The page fetches only that file and renders the badges. The browser never calls a backend directly. The checker runs as a systemd timer every 3 minutes. The live up/down check runs server-side. A checker on Dankclaw probes each backend and writes a same-origin `status.json`. The page fetches only that file and renders the badges. The browser never calls a backend directly. The checker runs as a systemd timer every 3 minutes.
@@ -111,7 +125,7 @@ flowchart TD
## Service classification ## Service classification
**User services** (root page), in section order: **User services** (root page), in section order:
- **AI** — OpenWebUI, and the three LLM fleet cards (frank-large, frank-medium, frank-secondary — status + docs link, no Open link). - **AI** — OpenWebUI, and the three LLM fleet cards (frank-large, frank-primary, frank-secondary — status + docs link, no Open link).
- **Web Apps** — SearXNG, Gitea, Copyparty, Marreta. - **Web Apps** — SearXNG, Gitea, Copyparty, Marreta.
- **Media Library Apps** — Calibre-Web, RomM, Plex, Jellyfin, Seerr. - **Media Library Apps** — Calibre-Web, RomM, Plex, Jellyfin, Seerr.
@@ -131,7 +145,8 @@ shared/homepage/
├── style.css # full theme; a toggle strips to bare HTML ├── style.css # full theme; a toggle strips to bare HTML
├── app.js # loads status.json once, renders the panels ├── app.js # loads status.json once, renders the panels
├── services.json # service config (user + admin) ├── services.json # service config (user + admin)
└── fonts/ # Terminus (woff2) for @font-face ├── img/ # screenshots served at /img (docs page; deployed with the page)
└── fonts/ # optional self-hosted fonts (deployed with the page; VT323 comes from the Google Fonts CDN, this dir is absent)
``` ```
`status.json` is generated at runtime in `/srv/homepage/`. It is not committed. `status.json` is generated at runtime in `/srv/homepage/`. It is not committed.
+3
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>frank — admin</title> <title>frank — admin</title>
<base href="/"> <base href="/">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=VT323&display=swap">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
+100 -17
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>frank — docs</title> <title>frank — docs</title>
<base href="/"> <base href="/">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=VT323&display=swap">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
@@ -31,13 +34,18 @@
<section id="chat"> <section id="chat">
<h1>Talking to the AI</h1> <h1>Talking to the AI</h1>
<p>The everyday front door is <a href="https://ai.franksdank.cloud">Open WebUI</a> <p>The everyday front door is <a href="https://ai.franksdank.cloud">Open&nbsp;WebUI</a>,
(it has its own login the first time; use the same email you use everywhere). the family chat app. Open the address, click <strong>Continue with authentik</strong>,
Pick a model in the chat's model selector — the three models the fleet runs are and use the same single sign-on as everything else — no separate password. Your
described <a href="#models">below</a>. Open WebUI keeps your chat history; the raw conversations are saved to your account, so history follows you between devices.</p>
model endpoints do not.</p> <img src="img/openwebui-chat.png" alt="Open WebUI chat screen with a question and the AI's answer">
<p>Images go to <code>frank-primary</code> or <code>frank-secondary</code> <p>Below the text box, the model box on the right chooses which model answers you.
(both see them). Audio (WAV) goes to <code>frank-secondary</code> only.</p> Family accounts get <code>frank-secondary</code> — the fleet's fast all-rounder,
which also sees images: drag a photo or screenshot straight into the chat box.
Frank's account can additionally reach the bigger models.</p>
<img src="img/openwebui-model-picker.png" alt="The Open WebUI welcome screen with frank-secondary selected in the model box">
<p>Open WebUI keeps your chat history; the raw model endpoints (used by developer
tools, see <a href="#api-access">API access</a>) do not.</p>
</section> </section>
<section id="models"> <section id="models">
@@ -47,27 +55,26 @@
<h2>frank-large — deep reasoning</h2> <h2>frank-large — deep reasoning</h2>
<ul> <ul>
<li><strong>Model:</strong> Qwen3.8-27B (UD-Q8_K_XL, 31.5 GiB).</li> <li><strong>Model:</strong> Qwen3.8-Flash-Next v2 Swift1.5 abliterated (halogen flash-server, ~62&nbsp;GiB) — an uncensored edition that thinks before answering.</li>
<li><strong>Capacity:</strong> 262k-token context, exactly one conversation at a time. Text only.</li> <li><strong>Capacity:</strong> 200k-token context, two conversations at a time; sees images.</li>
<li><strong>Inputs:</strong> text.</li> <li><strong>Inputs:</strong> text, image.</li>
<li><strong>Best for:</strong> cybersecurity and debugging. <em>Use requires Frank's permission.</em></li> <li><strong>Best for:</strong> cybersecurity and debugging. <em>Use requires Frank's permission.</em></li>
<li><strong>Not for:</strong> images. It cannot see them.</li>
</ul> </ul>
<h2>frank-primary — the primary model</h2> <h2>frank-primary — the primary model</h2>
<ul> <ul>
<li><strong>Model:</strong> Qwen3.8-Flash-Next (Q8_0, 175.3 GiB) with MTP + n-gram speculative drafting on both R9700s.</li> <li><strong>Model:</strong> Qwen3.8-Flash-Next at native FP8 precision (radiance, 173&nbsp;GiB across both R9700s) — the biggest brain on the network.</li>
<li><strong>Capacity:</strong> 262k-token context, exactly one conversation at a time; sees images.</li> <li><strong>Capacity:</strong> 200k-token context, two conversations at a time; sees images.</li>
<li><strong>Inputs:</strong> text, image.</li> <li><strong>Inputs:</strong> text, image.</li>
<li><strong>Best for:</strong> software engineering, system administration, and instruction tasks.</li> <li><strong>Best for:</strong> software engineering, system administration, and instruction tasks.</li>
</ul> </ul>
<h2>frank-secondary — the fast utility model</h2> <h2>frank-secondary — the fast utility model</h2>
<ul> <ul>
<li><strong>Model:</strong> Gemma4-12B QAT (Balanced, Q4_K_M, 7.4 GiB).</li> <li><strong>Model:</strong> Qwen3.5-9B-Uncensored (Q6_K, 6.9&nbsp;GiB) on the RTX&nbsp;3090 — roughly 95 tokens per second.</li>
<li><strong>Capacity:</strong> one million tokens of pooled context across four simultaneous conversations (up to 262k each); fastest at images.</li> <li><strong>Capacity:</strong> two simultaneous conversations, 200k tokens each; sees images.</li>
<li><strong>Inputs:</strong> text, image, audio.</li> <li><strong>Inputs:</strong> text, image.</li>
<li><strong>Best for:</strong> agentic search, RAG, research subagents, translation, roleplay, and summarization — any small task that doesn't need much intelligence, and anything with a picture (screenshots, photos, diagrams).</li> <li><strong>Best for:</strong> agentic search, RAG, research subagents, translation, roleplay, and summarization — any small task that doesn't need much intelligence, and anything with a picture (screenshots, photos, diagrams). This is the model Open&nbsp;WebUI offers to family accounts.</li>
</ul> </ul>
<p class="foot-note">Which one to pick? Small utility work, RAG, search, and <p class="foot-note">Which one to pick? Small utility work, RAG, search, and
@@ -76,6 +83,82 @@
<code>frank-large</code> (with Frank's permission).</p> <code>frank-large</code> (with Frank's permission).</p>
</section> </section>
<section id="api-access">
<h1>API access — the AI on your own laptop</h1>
<p>Developer assistants like <strong>Opencode</strong> and <strong>Pi</strong> are
chat programs that run on your own computer and can read your files, run commands,
and edit code while they talk. They are all built to speak the same standard
language as the big commercial products (the "OpenAI-compatible" API), and our
server speaks it too. You give the program three things — an address, a personal
key, and a model name — and your laptop thinks through our hardware.</p>
<h2>Step 1 — get your personal key</h2>
<ol>
<li>Open <a href="https://ai.franksdank.cloud">ai.franksdank.cloud</a> and log in.</li>
<li>Click your circle (bottom-left corner) → <strong>Settings</strong> → <strong>Account</strong>.</li>
<li>Scroll to <strong>API keys</strong> → press <strong>Show</strong> → <strong>Create new secret key</strong>.</li>
<li>Press the copy icon. The key looks like <code>sk-3f2a…</code>.</li>
</ol>
<img src="img/openwebui-api-keys.png" alt="The Open WebUI Account settings page with the API key field at the bottom">
<p class="key-warn">The key is a password. Anyone who has it can use our AI as
you. Paste it only into the program's config file — never into a shared chat,
a screenshot, or a git repository.</p>
<h2>Step 2 — the three details every tool asks for</h2>
<ul>
<li><strong>Base URL:</strong> <code>https://ai.franksdank.cloud/api/v1</code></li>
<li><strong>Model:</strong> <code>frank-secondary</code></li>
<li><strong>API key:</strong> the <code>sk-…</code> from step 1 — sent as the header <code>Authorization: Bearer sk-…</code></li>
</ul>
<p>Proof that it works, from any terminal (replace the key):</p>
<pre><code>curl https://ai.franksdank.cloud/api/v1/chat/completions \
-H "Authorization: Bearer PASTE-YOUR-KEY" \
-H "Content-Type: application/json" \
-d '{"model": "frank-secondary", "messages": [{"role": "user", "content": "Hello!"}]}'</code></pre>
<h2>Step 3a — Opencode</h2>
<p>Install once (<code>npm install -g opencode-ai</code>), then create the file
<code>~/.config/opencode/opencode.json</code> with:</p>
<pre><code>{
"$schema": "https://opencode.ai/config.json",
"provider": {
"frank": {
"npm": "@ai-sdk/openai-compatible",
"name": "Frank fleet",
"options": {
"baseURL": "https://ai.franksdank.cloud/api/v1",
"apiKey": "PASTE-YOUR-KEY"
},
"models": {
"frank-secondary": { "name": "frank-secondary" }
}
}
}
}</code></pre>
<p>Start <code>opencode</code> and pick the model with <code>/model</code> —
it appears as <code>frank/frank-secondary</code>.</p>
<h2>Step 3b — Pi</h2>
<p>Create the file <code>~/.pi/agent/models.json</code> with:</p>
<pre><code>{
"providers": {
"frank": {
"baseUrl": "https://ai.franksdank.cloud/api/v1",
"api": "openai-completions",
"apiKey": "PASTE-YOUR-KEY",
"models": [
{ "id": "frank-secondary" }
]
}
}
}</code></pre>
<p>Then start Pi with <code>pi --model frank/frank-secondary</code>.</p>
<p class="foot-note">The key works only for the family AI — it is not your
Authentik password, and losing it costs nothing beyond asking Frank to rotate it.
Want a bigger model (frank-primary, frank-large)? Ask Frank to raise your access.</p>
</section>
<section id="media"> <section id="media">
<h1>Media &amp; files</h1> <h1>Media &amp; files</h1>
<ul> <ul>
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+3
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>frank — services</title> <title>frank — services</title>
<base href="/"> <base href="/">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=VT323&display=swap">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
+4 -4
View File
@@ -269,8 +269,8 @@
"section": "AI", "section": "AI",
"details": { "details": {
"role": "Deep reasoning model", "role": "Deep reasoning model",
"spec": "Qwen3.8-27B (UD-Q8_K_XL, 31.5 GiB) — 262k-token context, one conversation at a time, text only.", "spec": "Qwen3.8-Flash-Next v2 Swift1.5 abliterated (halogen flash-server, ~62 GiB) — 200k-token context, two conversations at a time; sees images. Speculative decoding always on.",
"inputs": ["text"], "inputs": ["text", "image"],
"use": "Cybersecurity and debugging. Requires Frank's permission." "use": "Cybersecurity and debugging. Requires Frank's permission."
}, },
"url": "", "url": "",
@@ -286,7 +286,7 @@
"section": "AI", "section": "AI",
"details": { "details": {
"role": "Primary model", "role": "Primary model",
"spec": "Qwen3.8-Flash-Next (Q8_0, 175.3 GiB) — full 262k-token context, one conversation; sees images. Spec-drafted (MTP + n-gram) on both R9700s.", "spec": "Qwen3.8-Flash-Next at native FP8 precision (radiance, 173 GiB) — 200k-token context, two conversations; sees images. Runs on both R9700s with tiered expert memory.",
"inputs": ["text", "image"], "inputs": ["text", "image"],
"use": "Software engineering, system administration, and instruction tasks." "use": "Software engineering, system administration, and instruction tasks."
}, },
@@ -303,7 +303,7 @@
"section": "AI", "section": "AI",
"details": { "details": {
"role": "Fast utility model", "role": "Fast utility model",
"spec": "MiMo-V2.6-Distill-Qwen-9B (INT8 W8A16, 12 GiB) on SGLang — 606k-token pool across four 151k-token conversations; 84 tok/s decode; sees images.", "spec": "Qwen3.5-9B-Uncensored (Q6_K, 6.9 GiB) on llama.cpp CUDA — two 200k-token conversations at a time; sees images; ~95 tok/s decode on the RTX 3090.",
"inputs": ["text", "image"], "inputs": ["text", "image"],
"use": "Agentic search, RAG, research subagents, translation, roleplay, and summarization — any small task that doesn't need much intelligence." "use": "Agentic search, RAG, research subagents, translation, roleplay, and summarization — any small task that doesn't need much intelligence."
}, },
+25 -16
View File
@@ -1,17 +1,18 @@
/* The homepage theme: terminal TUI, green-on-black, 1995 phosphor palette. /* The homepage theme: terminal TUI, amber-on-black, CRT-AMBIOS palette with
A ?bare=1 toggle strips the page to bare, unstyled HTML (Q27). The page the VT323 bitmap font (Google Fonts CDN). A ?bare=1 toggle strips the page
never sets a custom or hidden cursor, so the user's cursor is untouched (Q28). */ to bare, unstyled HTML (Q27). The page never sets a custom or hidden cursor,
so the user's cursor is untouched (Q28). */
:root { :root {
--bg: #050805; --bg: #0a0600;
--fg: #33ff66; --fg: #ffb000;
--dim: #2f8f52; --dim: #a06a00;
--down: #ff5555; --down: #ff5555;
--wip: #ffcc33; --wip: #ffcc33;
--link: #33ccff; --link: #ffd75f;
--head: #aaffcc; --head: #ffe9a8;
--border: #1a4d2e; --border: #5c3d00;
--panel: #0a120a; --panel: #150c02;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -21,12 +22,12 @@ html { background: var(--bg); }
body { body {
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
font-family: "Terminus", "Fira Code", "DejaVu Sans Mono", "Consolas", monospace; font-family: "VT323", "Terminus", "Fira Code", "DejaVu Sans Mono", "Consolas", monospace;
font-size: 15px; font-size: 19px;
line-height: 1.55; line-height: 1.4;
margin: 0; margin: 0;
padding: 24px; padding: 24px;
text-shadow: 0 0 4px rgba(51, 255, 102, 0.30); text-shadow: 0 0 4px rgba(255, 176, 0, 0.30);
} }
/* Subtle scanlines. */ /* Subtle scanlines. */
@@ -96,13 +97,21 @@ body::before {
/* The user docs page (static prose). */ /* The user docs page (static prose). */
.docs { max-width: 780px; } .docs { max-width: 780px; }
.docs h1 { color: var(--head); font-size: 1.25rem; border-bottom: 1px solid var(--border); padding-bottom: 4px; } .docs h1 { color: var(--head); font-size: 1.7rem; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.docs h2 { color: var(--head); font-size: 1.05rem; margin-top: 20px; } .docs h2 { color: var(--head); font-size: 1.35rem; margin-top: 20px; }
.docs a { color: var(--link); } .docs a { color: var(--link); }
.docs code { color: var(--head); } .docs code { color: var(--head); }
.docs pre { background: var(--panel); border: 1px solid var(--border); border-radius: 2px; padding: 10px 12px; overflow-x: auto; }
.docs pre code { color: var(--fg); }
.docs img { display: block; max-width: 100%; border: 1px solid var(--border); border-radius: 2px; margin: 10px 0; }
.docs .key-warn { color: var(--down); }
.docs .foot-note { color: var(--dim); } .docs .foot-note { color: var(--dim); }
html[data-bare="1"] .docs h1, html[data-bare="1"] .docs h2 { color: #000; border-color: #000; } html[data-bare="1"] .docs h1, html[data-bare="1"] .docs h2 { color: #000; border-color: #000; }
html[data-bare="1"] .docs a, html[data-bare="1"] .docs code { color: #000; } html[data-bare="1"] .docs a, html[data-bare="1"] .docs code { color: #000; }
html[data-bare="1"] .docs pre { background: none; border-color: #000; }
html[data-bare="1"] .docs pre code { color: #000; }
html[data-bare="1"] .docs img { border-color: #000; }
html[data-bare="1"] .docs .key-warn { color: #000; }
/* Bare mode (Q27): strip to default browser rendering. */ /* Bare mode (Q27): strip to default browser rendering. */
html[data-bare="1"] body { background: #fff; color: #000; font-family: serif; text-shadow: none; font-size: 16px; } html[data-bare="1"] body { background: #fff; color: #000; font-family: serif; text-shadow: none; font-size: 16px; }