Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
06daa4d
style: align gateway WebUI with VocaHQ paper design
jatinkrmalik Aug 9, 2026
dce8740
ux: VocaPhone branding and denser onboarding layout
jatinkrmalik Aug 9, 2026
ee6b743
feat: system-aware light/dark theme with header toggle
jatinkrmalik Aug 9, 2026
90a0bcc
ux: move bind addresses into model pill hover card
jatinkrmalik Aug 9, 2026
8294162
ux: drop checklist and dependencies from Overview
jatinkrmalik Aug 9, 2026
fc0c0cb
ux: put theme toggle at the right edge of the header
jatinkrmalik Aug 9, 2026
8c7e164
ux: collapse model families and add filter panel
jatinkrmalik Aug 9, 2026
2500b96
ux: put live operations first on Overview
jatinkrmalik Aug 9, 2026
4adcc35
ux: restore system information on Overview
jatinkrmalik Aug 9, 2026
c518aab
style: use VocaHQ brand logos for light and dark themes
jatinkrmalik Aug 9, 2026
942045f
ux: add Expand all / Collapse all on Models
jatinkrmalik Aug 9, 2026
55a57c6
copy: humanize WebUI text and drop forced line measure
jatinkrmalik Aug 9, 2026
f7bc4df
ux: link model discovery and catalog requests
jatinkrmalik Aug 9, 2026
2f015f2
ux: icon tiles for system info and named GPUs
jatinkrmalik Aug 9, 2026
83eb278
ux: models filters rail, ops charts, exposure strip, brand polish
jatinkrmalik Aug 9, 2026
9a61fca
ux: keep family tiles in place with full-width model strip
jatinkrmalik Aug 9, 2026
549d552
fix: address ruff B006/SIM105 CI failures
jatinkrmalik Aug 9, 2026
738a7f5
fix: keep engine popover above sticky tabs
jatinkrmalik Aug 9, 2026
ed34299
ux: clarify open vs active families and fix clear-filters 422
jatinkrmalik Aug 9, 2026
617bb1b
ux: restore libraries and tools panel on Overview
jatinkrmalik Aug 9, 2026
665abd3
ux: restore Live operations metrics with outcome charts
jatinkrmalik Aug 9, 2026
bbfdd00
style: fit Live operations KPI grid without empty cells
jatinkrmalik Aug 9, 2026
85b0d7a
style: redesign Libraries & tools to match Overview uplift
jatinkrmalik Aug 9, 2026
9b2e64e
fix: satisfy ruff import blank line and format check
jatinkrmalik Aug 9, 2026
c90b19a
fix: ruff-format test_panel and admin tests
jatinkrmalik Aug 9, 2026
7c40f90
fix: tell operators to select a model when one is already installed
jatinkrmalik Aug 9, 2026
57bf423
fix: polish Models and Pair flows after redesign QA
jatinkrmalik Aug 9, 2026
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
55 changes: 50 additions & 5 deletions app/admin_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import importlib.util
from typing import Literal

from app.catalog import language_names, recommended_ids
from app.catalog import catalog_source_url, language_names, recommended_ids
from app.context import BOOTSTRAP_TOKEN_ID, TOKEN_FILE_HINT, VERSION, GatewayContext
from app.engine_state import active_model_path, available_engines, engine_id
from app.schemas import (
Expand Down Expand Up @@ -113,7 +113,7 @@ async def status_payload(ctx: GatewayContext) -> AdminStatusResponse:
]
readiness_details = await ctx.readiness.details()
state = readiness_details.health
metrics = ctx.service.metrics.snapshot()
metrics = ctx.service.metrics.snapshot(sample=True)
return AdminStatusResponse(
version=VERSION,
engine=EngineStatus(id=engine_id(ctx), name=state.name, ready=state.ready),
Expand Down Expand Up @@ -198,6 +198,7 @@ def model_entries(ctx: GatewayContext) -> list[AdminModelEntry]:
family=model.family,
description=model.description,
source=model.source,
source_url=catalog_source_url(model),
supports_streaming=model.supports_streaming,
license_name=model.license_name,
commercial_use=model.commercial_use,
Expand Down Expand Up @@ -234,14 +235,58 @@ def model_entries(ctx: GatewayContext) -> list[AdminModelEntry]:
return entries


# Download-size caps for the filter panel (decimal MB, same scale as the UI).
SIZE_FILTER_CAPS: dict[str, int] = {
"100mb": 100_000_000,
"300mb": 300_000_000,
"800mb": 800_000_000,
"1500mb": 1_500_000_000,
}


def _as_str_list(value: str | list[str] | None) -> list[str]:
"""Normalize FastAPI Query/Form values (single string, list, or empty)."""
if value is None:
return []
if isinstance(value, str):
return [value] if value else []
return [item for item in value if item]


def filtered_model_entries(
ctx: GatewayContext, installed_only: bool, language: str
ctx: GatewayContext,
installed_only: bool = False,
language: str | list[str] | None = None,
family: str | list[str] | None = None,
engine: str | list[str] | None = None,
max_size: str = "",
recommended_only: bool = False,
) -> list[AdminModelEntry]:
"""Filter the catalog. Within a multi-select dimension match is OR; across
dimensions match is AND. Languages use model_covers (empty codes = match all).
"""
languages = _as_str_list(language)
families = _as_str_list(family)
engines = _as_str_list(engine)
size_cap = SIZE_FILTER_CAPS.get(max_size.strip().lower()) if max_size else None

entries = model_entries(ctx)
if installed_only:
entries = [entry for entry in entries if entry.state == "installed"]
if language:
entries = [entry for entry in entries if model_covers(entry, language)]
if languages:
entries = [
entry for entry in entries if any(model_covers(entry, code) for code in languages)
]
if families:
allowed = set(families)
entries = [entry for entry in entries if entry.family in allowed]
if engines:
allowed_engines = set(engines)
entries = [entry for entry in entries if entry.engine in allowed_engines]
if size_cap is not None:
entries = [entry for entry in entries if entry.size_bytes <= size_cap]
if recommended_only:
entries = [entry for entry in entries if entry.recommended]
return entries


Expand Down
106 changes: 93 additions & 13 deletions app/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def _whisper_cpp(
description: str = "OpenAI Whisper converted for the standalone whisper.cpp engine.",
source: str = "whisper.cpp",
language_codes: tuple[str, ...] = (),
license_name: str = "See model source",
) -> CatalogModel:
return CatalogModel(
id=f"{ENGINE_WHISPER_CPP}:{key}",
Expand All @@ -83,6 +84,7 @@ def _whisper_cpp(
description=description,
source=source,
language_codes=language_codes or _whisper_language_codes(languages),
license_name=license_name,
)


Expand Down Expand Up @@ -138,7 +140,8 @@ def _faster_whisper(
huggingface_folder="",
family="Whisper / CTranslate2",
description=(
"Persistent CTranslate2 model with CPU INT8 inference; optimized for Linux servers."
"Persistent CTranslate2 model with CPU INT8 inference; "
"works well on desktop and server CPUs."
),
source="faster-whisper",
marker_file="model.bin",
Expand Down Expand Up @@ -419,6 +422,72 @@ def _whisper_language_codes(languages: str) -> tuple[str, ...]:
}


def catalog_source_url(model: CatalogModel) -> str | None:
"""Best public page for a catalog entry (Hugging Face repo or project site).

Prefers a browsable page over a raw .tar/.bin download blob.
"""
if model.huggingface_repo:
return f"https://huggingface.co/{model.huggingface_repo}"
if model.download_url and "huggingface.co/" in model.download_url:
url = model.download_url
if "/resolve/" in url:
head, _, _ = url.partition("/resolve/")
return head
return url
# Release/tag pages are more specific than a project root (e.g. SenseVoice /
# Parakeet v3 ship only as sherpa-onnx GitHub release assets).
release = _github_release_page(model.archive_url)
if release:
return release
# Label-specific pages (Handy, Breeze, …) before generic engine fallbacks —
# otherwise Handy builds incorrectly link to whisper.cpp's GitHub.
labeled = _SOURCE_LABEL_URLS.get(model.source)
if labeled:
return labeled
project = _ENGINE_SOURCE_URLS.get(model.engine)
if project:
return project
if model.download_url:
return model.download_url
if model.archive_url:
return model.archive_url
return None


def _github_release_page(archive_url: str | None) -> str | None:
"""Turn a GitHub release asset URL into the browsable release/tag page."""
if not archive_url or "/releases/download/" not in archive_url:
return None
head, _, rest = archive_url.partition("/releases/download/")
if not head.startswith("https://github.com/") or not rest:
return None
tag = rest.split("/", maxsplit=1)[0]
return f"{head}/releases/tag/{tag}" if tag else None


_ENGINE_SOURCE_URLS = {
ENGINE_WHISPER_CPP: "https://github.com/ggml-org/whisper.cpp",
ENGINE_WHISPERKIT: "https://github.com/argmaxinc/WhisperKit",
ENGINE_FASTER_WHISPER: "https://github.com/SYSTRAN/faster-whisper",
ENGINE_MOONSHINE: "https://github.com/moonshine-ai/moonshine",
ENGINE_SHERPA_ONNX: "https://github.com/k2-fsa/sherpa-onnx",
ENGINE_MLX_AUDIO: "https://github.com/Blaizzy/mlx-audio",
}

_SOURCE_LABEL_URLS = {
"whisper.cpp": "https://github.com/ggml-org/whisper.cpp",
"faster-whisper": "https://github.com/SYSTRAN/faster-whisper",
"WhisperKit": "https://github.com/argmaxinc/WhisperKit",
"Moonshine Voice": "https://github.com/moonshine-ai/moonshine",
"sherpa-onnx": "https://github.com/k2-fsa/sherpa-onnx",
"MLX Audio": "https://github.com/Blaizzy/mlx-audio",
# Hosted on Handy's CDN; the product page is the right "source", not whisper.cpp.
"Handy-compatible": "https://handy.computer",
"Breeze ASR": "https://huggingface.co/MediaTek-Research/Breeze-ASR-25",
}


def language_names(codes: tuple[str, ...]) -> list[str]:
"""Human-readable names for a model's languages, in the order declared."""
return [LANGUAGE_NAMES.get(code, code) for code in codes]
Expand Down Expand Up @@ -577,9 +646,8 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
language_codes=("en",),
family="Parakeet TDT",
description=(
"The English-only Parakeet. v3 traded English accuracy for 25-language coverage, so "
"this earlier release still transcribes English more accurately than the v3 entry "
"above at the same speed."
"The English-only Parakeet. v3 trades some English accuracy for 25-language coverage, "
"so this earlier release still transcribes English more accurately at the same speed."
),
license_name="CC BY 4.0",
),
Expand Down Expand Up @@ -657,9 +725,8 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
language_codes=("en",),
family="Zipformer",
description=(
"A small streaming-capable zipformer transducer. Unlike the other sherpa-onnx "
"models above, this one decodes incrementally over /v1/stream with real partial "
"results, independent of Moonshine."
"A small streaming-capable zipformer transducer. Unlike most batch sherpa-onnx "
"models, this one decodes incrementally with real partial results while you speak."
),
license_name="Apache 2.0",
supports_streaming=True,
Expand Down Expand Up @@ -803,8 +870,8 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
repository="mlx-community/parakeet-tdt-0.6b-v2",
family="Parakeet TDT / MLX",
description=(
"The English-only Parakeet on Apple silicon. More accurate on English than the v3 "
"entry above, which spends capacity on 24 other languages."
"The English-only Parakeet on Apple silicon. More accurate on English than the "
"multilingual v3 build, which spends capacity on 24 other languages."
),
license_name="CC BY 4.0",
language_codes=("en",),
Expand Down Expand Up @@ -980,8 +1047,13 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
"Accurate · compact",
8,
download_url="https://blob.handy.computer/whisper-medium-q4_1.bin",
description="Handy's compact Whisper Medium build, usable without the Handy app.",
description=(
"Handy's compact quantized Whisper Medium (Q4). Same whisper.cpp runtime; weights "
"are hosted on Handy's CDN and work without the Handy app."
),
source="Handy-compatible",
# MIT Whisper weights; Handy redistributes the quant.
# License string left generic so the UI links through to Handy rather than inventing one.
),
_whisper_cpp(
"ggml-large-v3-turbo.bin",
Expand All @@ -999,7 +1071,10 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
"Most accurate · compact",
16,
download_url="https://blob.handy.computer/ggml-large-v3-q5_0.bin",
description="Quantized Whisper Large v3 from Handy's standalone model catalog.",
description=(
"Quantized Whisper Large v3 (Q5) from Handy's model catalog. Larger and more accurate "
"than Medium Q4; still runs through the local whisper.cpp engine."
),
source="Handy-compatible",
),
_whisper_cpp(
Expand All @@ -1011,9 +1086,14 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
16,
download_url="https://blob.handy.computer/breeze-asr-q5_k.bin",
family="Breeze ASR",
description="Whisper variant tuned for Taiwanese Mandarin and code-switching.",
source="Handy-compatible",
description=(
"MediaTek Breeze-ASR (Whisper Large v2 fine-tune) quantized to Q5 for whisper.cpp. "
"Tuned for Taiwanese Mandarin and Mandarin–English code-switching; "
"weights redistributed via Handy's CDN."
),
source="Breeze ASR",
language_codes=("zh", "en"),
license_name="Apache 2.0",
),
_whisper_cpp(
"ggml-large-v3.bin", "whisper.cpp Large v3", 3 * GB, "Multilingual", "Most accurate", 24
Expand Down
2 changes: 1 addition & 1 deletion app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def serve() -> None:
host = settings.bind_host
token_path = settings.token_file_display
token_source = "(from VOCAPHONE_TOKEN)" if _token_from_env() else token_path
print(f"vocaphone gateway listening on {format_host_port(host, settings.port)}")
print(f"VocaGateway listening on {format_host_port(host, settings.port)}")
print(f"WebUI (this host): {local_webui_url(host, settings.port)}")
if host in WILDCARD_BIND_HOSTS:
print("Network access: use this host's LAN or Tailscale IP with the same port")
Expand Down
72 changes: 53 additions & 19 deletions app/fragments/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from html import escape

from app.config import format_host_port, local_webui_url
from app.schemas import EngineStatus
from app.system import engine_requirement

Expand All @@ -18,20 +19,20 @@
}

ENGINE_HINTS = {
"auto": "Uses the fastest compatible installed local engine for this machine.",
"auto": "Picks the fastest compatible local engine already installed on this machine.",
"vocamac": (
"Optional Apple silicon Mac app. Reuses VocaMac's downloaded Core ML "
"models through whisperkit-cli. No download needed."
"Optional Apple silicon Mac app. Reuses VocaMac's downloaded Core ML models "
"via whisperkit-cli. No separate download."
),
"handy": (
"Optional macOS app. Reuses the Handy app and its downloaded models. No download needed."
"Optional macOS app. Reuses the Handy app and its downloaded models. No separate download."
),
"whisper.cpp": "Runs local GGML models with the whisper-cli binary.",
"whisperkit": "Runs Core ML models with whisperkit-cli on Apple Silicon Macs.",
"faster-whisper": "Keeps a CTranslate2 model loaded; CPU INT8 is the Linux default.",
"moonshine": "Fast, language-specific local models; compatible English tiers stream live.",
"whisper.cpp": "Local GGML models via the whisper-cli binary.",
"whisperkit": "Core ML models via whisperkit-cli on Apple silicon Macs.",
"faster-whisper": "Keeps a CTranslate2 model loaded. CPU INT8 is the usual Linux default.",
"moonshine": "Fast language-specific models. Compatible English tiers can stream live.",
"sherpa-onnx": "Compact INT8 CPU models for fast macOS and Linux transcription.",
"mlx-audio": "Runs Apple-silicon-native MLX models with persistent loading.",
"mlx-audio": "Apple-silicon MLX models with persistent loading.",
}


Expand All @@ -42,31 +43,64 @@ def _engine_option_label(engine: str) -> str:
return f"{label} ({requirement} only)" if requirement else label


def _engine_status(engine: EngineStatus, *, oob: bool = False) -> str:
"""The engine indicator in the header: a status dot and the engine's name."""
def _engine_status(
engine: EngineStatus,
*,
bind_host: str = "0.0.0.0",
port: int = 8765,
oob: bool = False,
) -> str:
"""Header control: active model, network details on hover, opens Models on click."""
classes = "engine-status ready" if engine.ready else "engine-status"
dot = "ok" if engine.ready else "warn"
label = escape(engine.name or engine.id)
listener = escape(format_host_port(bind_host, port))
local_url = escape(local_webui_url(bind_host, port))
ready_label = "Ready" if engine.ready else "Not ready"
swap_oob = ' hx-swap-oob="true"' if oob else ""
# Network addresses live in the hover card so Overview stays uncluttered.
return (
f'<div id="engine-pill" class="{classes}"{swap_oob}'
f'<button type="button" id="engine-pill" class="{classes}"{swap_oob}'
f' data-open-tab="models"'
f' aria-label="Speech model {label}, {ready_label}. '
f'Listener {listener}. WebUI {local_url}. Opens Models."'
f' hx-get="/ui/partials/engine-pill" hx-trigger="every 5s" hx-swap="outerHTML">'
f'<span class="dot {dot}" aria-hidden="true"></span><span>{label}</span></div>'
f'<span class="dot {dot}" aria-hidden="true"></span>'
f"<span>{label}</span>"
f'<span class="engine-status-hint" aria-hidden="true">Models</span>'
f'<span class="engine-popover" role="tooltip">'
f'<span class="engine-popover-card">'
f'<span class="engine-popover-title">{ready_label}</span>'
f'<span class="engine-popover-row"><span>Listener</span>'
f"<code>{listener}</code></span>"
f'<span class="engine-popover-row"><span>This host</span>'
f"<code>{local_url}</code></span>"
f'<span class="engine-popover-hint">Click to open Models</span>'
f"</span></span></button>"
)


def engine_pill_fragment(engine: EngineStatus) -> str:
return _engine_status(engine)
def engine_pill_fragment(
engine: EngineStatus, *, bind_host: str = "0.0.0.0", port: int = 8765
) -> str:
return _engine_status(engine, bind_host=bind_host, port=port)


def engine_pill_oob(engine: EngineStatus) -> str:
return _engine_status(engine, oob=True)
def engine_pill_oob(engine: EngineStatus, *, bind_host: str = "0.0.0.0", port: int = 8765) -> str:
return _engine_status(engine, bind_host=bind_host, port=port, oob=True)


def engine_update_fragment(engine: EngineStatus, message: str) -> str:
def engine_update_fragment(
engine: EngineStatus,
message: str,
*,
bind_host: str = "0.0.0.0",
port: int = 8765,
) -> str:
css = "ok" if engine.ready else "missing"
return (
f'<span class="badge {css}">{escape(engine.name or engine.id)}'
f"{' ready' if engine.ready else ' not ready'}</span> "
f"{escape(message)}{engine_pill_oob(engine)}"
f"{escape(message)}"
f"{engine_pill_oob(engine, bind_host=bind_host, port=port)}"
)
Loading