Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions app/templates/models/page.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
{% from "macros/icons.html" import filter as filter_icon, expand as expand_icon, collapse as collapse_icon, refresh as refresh_icon, collapse_left %}
<div class="page-head">
<div>
<p class="eyebrow">SPEECH LIBRARY</p>
<h2>Models</h2>
<p>{{ installed }} of {{ total }} downloaded across {{ families }} families,
all stored on this machine. Families start collapsed; open one to pick a model.</p>
all stored on this machine. Open a family, download a model, then select it to start transcribing.</p>
</div>
<div class="models-toolbar-actions">
<button type="button" class="ghost filter-trigger" id="filter-rail-toggle"
Expand All @@ -29,6 +30,11 @@ <h2>Models</h2>
hx-target="#models-list" hx-swap="innerHTML">{{ refresh_icon() }}</button>
</div>
</div>
<div class="catalog-views" role="group" aria-label="Browse models">
<button type="button" class="ghost" data-model-view="all" aria-pressed="true">All models</button>
<button type="button" class="ghost" data-model-view="recommended" aria-pressed="false">Fits this machine</button>
<button type="button" class="ghost" data-model-view="installed" aria-pressed="false">Downloaded</button>
</div>
<div class="models-layout" id="models-layout">
<aside class="models-filter" id="models-filter" aria-label="Model filters">
<div class="filter-side-head">
Expand Down Expand Up @@ -111,20 +117,20 @@ <h3 class="filter-side-title">Filters</h3>
<div id="models-list" aria-live="polite">
{{ list_html }}
</div>
<div class="card">
<h2>Bring your own Whisper model</h2>
<details class="card disclosure">
<summary>Bring your own Whisper model</summary>
<p class="muted">Paste a direct HTTPS link to a <code>.bin</code> or
<code>.gguf</code> file. It runs through the standalone engine.
Use a <code>/resolve/</code> URL on Hugging Face, not the repo home page.</p>
<form hx-post="/ui/partials/models/custom" hx-include="#models-filter-form"
hx-target="#models-list" hx-swap="innerHTML">
<div class="row">
<input name="url" type="url" required
<input name="url" type="url" required aria-label="Model download URL"
placeholder="https://huggingface.co/&hellip;/resolve/main/model.gguf" />
<button type="submit" class="primary">Download</button>
</div>
<div class="row">
<input name="sha256" type="text" spellcheck="false"
<input name="sha256" type="text" aria-label="Optional SHA-256 digest" spellcheck="false"
pattern="\s*(?:[Ss][Hh][Aa]256:)?[0-9a-fA-F]{64}\s*"
title="64 hexadecimal characters, optionally prefixed with sha256:"
placeholder="Optional SHA-256 from the model card (verified on download)" />
Expand All @@ -140,6 +146,6 @@ <h2>Bring your own Whisper model</h2>
<a href="{{ request_model_issue_url }}" target="_blank"
rel="noopener noreferrer">Request a catalog model</a>
</p>
</div>
</details>
</div>
</div>
24 changes: 16 additions & 8 deletions app/templates/overview/operations.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@
hx-get="/ui/partials/operations" hx-trigger="every 5s"
hx-swap="outerHTML" aria-label="Gateway operations">
<div class="section-heading">
<h2>Live operations <span class="muted">&middot; since start</span></h2>
<h2>Activity <span class="muted">&middot; since start</span></h2>
<span class="probe-age">Engine checked {{ probe_age }}s ago &middot;
{{ sample_note }}</span>
</div>
<div class="operations-grid operations-grid-kpis">
{% for card in metric_cards %}
{% for card in metric_cards[:4] %}
{{ metric_card(card) }}
{% endfor %}
</div>
Expand All @@ -44,11 +44,19 @@ <h2>Live operations <span class="muted">&middot; since start</span></h2>
<span class="ops-bar-seg ops-bar-free" style="width:{{ capacity.free_pct }}%"></span>
</div>
</div>
<div class="ops-charts ops-charts-inline" aria-label="Recent activity">
<div class="ops-charts-grid ops-charts-grid-3">
{{ ops_chart(latency_chart) }}
{{ ops_chart(outcomes_chart) }}
{{ ops_chart(pipeline_chart) }}
<details class="activity-details">
<summary>Performance details</summary>
<div class="operations-grid operations-grid-kpis">
{% for card in metric_cards[4:] %}
{{ metric_card(card) }}
{% endfor %}
</div>
</div>
<div class="ops-charts ops-charts-inline" aria-label="Recent activity">
<div class="ops-charts-grid ops-charts-grid-3">
{{ ops_chart(latency_chart) }}
{{ ops_chart(outcomes_chart) }}
{{ ops_chart(pipeline_chart) }}
</div>
</div>
</details>
</section>
19 changes: 16 additions & 3 deletions app/templates/overview/page.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
<div class="page-head">
<div>
<p class="eyebrow">YOUR WORKSPACE</p>
<h2>Overview</h2>
<p>Speech-to-text, on the machine you control.</p>
</div>
</div>
<section class="status-hero">
<div class="headline">
<span class="dot {{ "ok" if ready else "warn" }}" aria-hidden="true"></span>
Expand All @@ -7,7 +14,13 @@ <h2>{{ "Ready for dictation" if ready else "Setup still needed" }}</h2>
</div>
</div>
</section>
{{ operations_html }}
{{ system_panel_html }}
{{ dependencies_html }}
{{ onboarding_html }}
{{ operations_html }}
<details class="disclosure card">
<summary>
<span>Server details</span>
<span class="muted small">Hardware, speech engines &amp; dependencies</span>
</summary>
{{ system_panel_html }}
{{ dependencies_html }}
</details>
6 changes: 3 additions & 3 deletions app/templates/pairing/card.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ <h2>Pair phone</h2>
</div>
</div>

<div class="pairing-advanced">
<h3 class="pairing-advanced-title">Optional</h3>
<details class="pairing-advanced">
<summary>Custom address &amp; device access</summary>
<div class="pairing-advanced-grid">
<form class="pairing-panel" hx-get="/ui/partials/pairing"
hx-target="#pairing-card" hx-swap="outerHTML">
Expand Down Expand Up @@ -143,7 +143,7 @@ <h4 class="pairing-subhead">Saved addresses</h4>
</ul>
</div>
{% endif %}
</div>
</details>

<p class="pairing-note">
Prefer LAN Wi-Fi when the phone is on the same network; use Tailscale when
Expand Down
4 changes: 2 additions & 2 deletions app/templates/settings/page.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div class="page-head">
<div>
<h2>Settings</h2>
<p class="eyebrow">PREFERENCES &amp; ACCESS</p><h2>Settings</h2>
<p>Engine, network paths, device tokens, and browser tools for this gateway.</p>
</div>
</div>
Expand Down Expand Up @@ -67,7 +67,7 @@ <h2>Speech engine</h2>
<p class="muted small settings-hint">The next transcription loads an offloaded model again.
One-shot engines such as Handy do not keep a model resident.</p>
<div class="row settings-actions">
<button type="submit" class="primary">Apply</button>
<button type="submit" class="primary">Save settings</button>
</div>
</form>
<p id="engine-result" class="muted settings-engine-result">{{ engine_hint }}</p>
Expand Down
6 changes: 4 additions & 2 deletions app/templates/test_panel/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
swaps cannot stack duplicates. #}
<div class="page-head">
<div>
<h2>Pair &amp; test</h2>
<p class="eyebrow">CONNECT YOUR PHONE</p><h2>Pair &amp; test</h2>
<p>Connect the phone app with a QR, then record a short clip here to
verify the same path the phone uses.</p>
</div>
</div>
<div class="flow-step"><span class="step-number">1</span><div><h3>Connect VocaPhone</h3><p class="muted">Keep your phone and gateway on the same trusted network or VPN.</p></div></div>
{{ pairing_html }}
<div class="flow-step"><span class="step-number">2</span><div><h3>Try a transcription</h3><p class="muted">Use this browser to check that your selected model can turn speech into text.</p></div></div>
{{ test_card_html }}
{{ network_html }}
<details class="card disclosure"><summary>Connection &amp; network help</summary>{{ network_html }}</details>
4 changes: 2 additions & 2 deletions app/templates/test_panel/test_card.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<div class="section-heading">
<h2>Test dictation</h2>
</div>
<p class="muted">Uses this browser&rsquo;s microphone, then FFmpeg and the
active engine (same path as the phone app).</p>
<p class="muted">Record a short clip. Audio is sent to this gateway machine and transcribed
with your selected speech-to-text model.</p>
<div class="test-controls" id="recorder-controls"
data-maximum-seconds="{{ maximum_duration_seconds }}">
<label class="test-field">
Expand Down
54 changes: 48 additions & 6 deletions app/webui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,19 @@
tokenError.classList.toggle("hidden", !message);
overlay.classList.remove("hidden");
overlay.setAttribute("aria-hidden", "false");
document.querySelectorAll(".app-header, .tabs, main, .site-footer").forEach((el) => {
el.inert = true;
});
tokenInput.focus();
}

function hideOverlay() {
overlay.classList.add("hidden");
overlay.setAttribute("aria-hidden", "true");
tokenInput.value = "";
document.querySelectorAll(".app-header, .tabs, main, .site-footer").forEach((el) => {
el.inert = false;
});
}

// ------------------------------------------------------------------ token
Expand All @@ -159,7 +165,7 @@
}
localStorage.setItem(TOKEN_KEY, token);
hideOverlay();
htmx.ajax("GET", "/ui/partials/overview", { target: "#panel", swap: "innerHTML" });
openTabByName(document.querySelector(".tab.active")?.dataset.tab || "overview");
htmx.ajax("GET", "/ui/partials/engine-pill", { target: "#engine-pill", swap: "outerHTML" });
htmx.ajax("GET", "/ui/partials/exposure-banner", {
target: "#exposure-banner",
Expand All @@ -186,8 +192,9 @@

document.body.addEventListener("htmx:responseError", (event) => {
if (event.detail.xhr.status === 401) {
const hadToken = Boolean(getToken());
localStorage.removeItem(TOKEN_KEY);
showOverlay("Token rejected. Paste the current gateway token.");
showOverlay(hadToken ? "Token rejected. Paste the current gateway token." : "");
return;
}
let message = `Request failed (${event.detail.xhr.status}).`;
Expand Down Expand Up @@ -230,10 +237,13 @@
activateTab(tab);
});
tab.addEventListener("keydown", (event) => {
if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const tabs = [...document.querySelectorAll(".tab")];
const offset = event.key === "ArrowRight" ? 1 : -1;
const next = tabs[(tabs.indexOf(tab) + offset + tabs.length) % tabs.length];
const offset = ["ArrowRight", "ArrowDown"].includes(event.key) ? 1 : -1;
const index = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1
: (tabs.indexOf(tab) + offset + tabs.length) % tabs.length;
const next = tabs[index];
next.focus();
next.click();
});
Expand Down Expand Up @@ -261,8 +271,12 @@
}
});

let performanceDetailsOpen = false;
document.body.addEventListener("htmx:beforeSwap", (event) => {
const target = event.detail && event.detail.target;
if (target && target.id === "operations") {
performanceDetailsOpen = Boolean(target.querySelector(".activity-details[open]"));
}
// Download / Load / Cancel / poll refresh replace #models-list; keep open families.
if (target && target.id === "models-list") {
window.__openModelFamilies = openFamilyNames();
Expand All @@ -278,6 +292,10 @@
});

document.body.addEventListener("htmx:afterSwap", (event) => {
if (event.detail?.target?.id === "operations") {
const details = document.querySelector("#operations .activity-details");
if (details) details.open = performanceDetailsOpen;
}
scheduleModelPoll();
if (document.getElementById("test-language")) syncTestLanguages();
// Models tab shell (or list refresh) may reintroduce filter controls.
Expand Down Expand Up @@ -341,7 +359,7 @@

function isFilterRailCollapsed() {
try {
return localStorage.getItem(FILTER_RAIL_KEY) === "1";
return localStorage.getItem(FILTER_RAIL_KEY) !== "0";
} catch (_) {
return false;
}
Expand Down Expand Up @@ -503,7 +521,31 @@
.forEach((tile) => placeModelsAfterRow(tile));
}

function syncModelViews() {
const installed = document.getElementById("installed-only-toggle")?.checked;
const recommended = document.getElementById("recommended-only-toggle")?.checked;
document.querySelectorAll("[data-model-view]").forEach((button) => {
const view = button.dataset.modelView;
button.setAttribute("aria-pressed", String(view === "all" ? !installed && !recommended
: view === "installed" ? installed && !recommended : recommended && !installed));
});
}

document.body.addEventListener("click", (event) => {
const button = event.target.closest("[data-model-view]");
if (!button) return;
const form = document.getElementById("models-filter-form");
const installed = document.getElementById("installed-only-toggle");
const recommended = document.getElementById("recommended-only-toggle");
if (!form || !installed || !recommended) return;
installed.checked = button.dataset.modelView === "installed";
recommended.checked = button.dataset.modelView === "recommended";
installed.dispatchEvent(new Event("change", { bubbles: true }));
syncModelViews();
});

function initModelFilters() {
syncModelViews();
const form = document.getElementById("models-filter-form");
const clear = document.getElementById("filter-clear");
if (clear && !clear.dataset.bound) {
Expand Down
26 changes: 12 additions & 14 deletions app/webui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@
<!-- Favicon: circular mark. Header uses the square app icon (same kit as vocahq.com). -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon-light.svg" id="favicon" />
<link rel="apple-touch-icon" href="/assets/favicon-light.svg" id="apple-touch-icon" />
<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=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=Epilogue:wght@500;600;700&display=swap" />
<link rel="stylesheet" href="/assets/styles.css" />
<!-- External boot script (CSP script-src 'self') so theme applies before paint. -->
<script src="/assets/theme-boot.js"></script>
Expand All @@ -32,25 +29,26 @@

<div id="token-overlay" class="overlay hidden" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="token-title">
<h2 id="token-title">Gateway token</h2>
<p class="muted">Stored only in this browser. On this host:</p>
<p class="eyebrow">WELCOME TO VOCAGATEWAY</p>
<h2 id="token-title">Connect to your gateway</h2>
<p class="muted">Enter your gateway token to manage speech models and connect your phone. Stored only in this browser. On the gateway machine, run:</p>
<code class="block">cat ~/.config/vocagateway/token</code>
<label class="sr-only" for="token-input">Bearer token</label>
<label for="token-input">Gateway token</label>
<input id="token-input" type="password" placeholder="Bearer token" autocomplete="off" spellcheck="false" />
<p id="token-error" class="error hidden" role="alert"></p>
<button id="token-save" type="button" class="primary">Connect</button>
</div>
</div>

<header>
<header class="app-header">
<div class="brand">
<span class="logo-stack" aria-hidden="true">
<img class="logo logo-for-light" src="/assets/voca-logo.svg" width="28" height="28" alt="" />
<img class="logo logo-for-dark" src="/assets/voca-logo.svg" width="28" height="28" alt="" />
</span>
<div>
<h1>VocaGateway</h1>
<span class="muted">Private transcription gateway</span>
<span class="muted">Your self-hosted workspace</span>
</div>
</div>
<div class="header-actions">
Expand Down Expand Up @@ -79,22 +77,22 @@ <h1>VocaGateway</h1>
<nav class="tabs" role="tablist" aria-label="Gateway sections">
<button type="button" class="tab active" data-tab="overview" role="tab"
aria-selected="true" aria-controls="panel" id="tab-overview"
hx-get="/ui/partials/overview" hx-target="#panel" hx-swap="innerHTML">Overview</button>
hx-get="/ui/partials/overview" hx-target="#panel" hx-swap="innerHTML"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 10l9-7 9 7v10H3z M9 20v-7h6v7" /></svg><span>Overview</span></button>
<button type="button" class="tab" data-tab="models" role="tab"
aria-selected="false" aria-controls="panel" id="tab-models"
hx-get="/ui/partials/models" hx-target="#panel" hx-swap="innerHTML">Models</button>
hx-get="/ui/partials/models" hx-target="#panel" hx-swap="innerHTML"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16v6H4z M4 14h16v6H4z" /></svg><span>Models</span></button>
<button type="button" class="tab" data-tab="pair" role="tab"
aria-selected="false" aria-controls="panel" id="tab-pair"
hx-get="/ui/partials/test" hx-target="#panel" hx-swap="innerHTML">Pair &amp; test</button>
hx-get="/ui/partials/test" hx-target="#panel" hx-swap="innerHTML"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2h8v20H8z M11 18h2" /></svg><span>Pair &amp; test</span></button>
<button type="button" class="tab" data-tab="settings" role="tab"
aria-selected="false" aria-controls="panel" id="tab-settings"
hx-get="/ui/partials/settings" hx-target="#panel" hx-swap="innerHTML">Settings</button>
hx-get="/ui/partials/settings" hx-target="#panel" hx-swap="innerHTML"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16M8 3v6M16 9v6M10 15v6" /></svg><span>Settings</span></button>
<button type="button" class="tab" data-tab="about" role="tab"
aria-selected="false" aria-controls="panel" id="tab-about"
hx-get="/ui/partials/about" hx-target="#panel" hx-swap="innerHTML">About</button>
hx-get="/ui/partials/about" hx-target="#panel" hx-swap="innerHTML"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 11v6M12 7h.01 M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0" /></svg><span>About</span></button>
</nav>

<main>
<main id="workspace">
<div id="panel" role="tabpanel" aria-labelledby="tab-overview" aria-live="polite"
hx-get="/ui/partials/overview" hx-trigger="load" hx-swap="innerHTML">
<p class="muted loading">Loading&hellip;</p>
Expand Down
Loading