Skip to content

Commit fb1e564

Browse files
authored
Merge pull request #3 from VocaHQ/style/align-webui-vocahq-paper
style: VocaGateway WebUI redesign and operator polish
2 parents 2f5dea5 + 57bf423 commit fb1e564

30 files changed

Lines changed: 5228 additions & 812 deletions

app/admin_queries.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import importlib.util
44
from typing import Literal
55

6-
from app.catalog import language_names, recommended_ids
6+
from app.catalog import catalog_source_url, language_names, recommended_ids
77
from app.context import BOOTSTRAP_TOKEN_ID, TOKEN_FILE_HINT, VERSION, GatewayContext
88
from app.engine_state import active_model_path, available_engines, engine_id
99
from app.schemas import (
@@ -113,7 +113,7 @@ async def status_payload(ctx: GatewayContext) -> AdminStatusResponse:
113113
]
114114
readiness_details = await ctx.readiness.details()
115115
state = readiness_details.health
116-
metrics = ctx.service.metrics.snapshot()
116+
metrics = ctx.service.metrics.snapshot(sample=True)
117117
return AdminStatusResponse(
118118
version=VERSION,
119119
engine=EngineStatus(id=engine_id(ctx), name=state.name, ready=state.ready),
@@ -198,6 +198,7 @@ def model_entries(ctx: GatewayContext) -> list[AdminModelEntry]:
198198
family=model.family,
199199
description=model.description,
200200
source=model.source,
201+
source_url=catalog_source_url(model),
201202
supports_streaming=model.supports_streaming,
202203
license_name=model.license_name,
203204
commercial_use=model.commercial_use,
@@ -234,14 +235,58 @@ def model_entries(ctx: GatewayContext) -> list[AdminModelEntry]:
234235
return entries
235236

236237

238+
# Download-size caps for the filter panel (decimal MB, same scale as the UI).
239+
SIZE_FILTER_CAPS: dict[str, int] = {
240+
"100mb": 100_000_000,
241+
"300mb": 300_000_000,
242+
"800mb": 800_000_000,
243+
"1500mb": 1_500_000_000,
244+
}
245+
246+
247+
def _as_str_list(value: str | list[str] | None) -> list[str]:
248+
"""Normalize FastAPI Query/Form values (single string, list, or empty)."""
249+
if value is None:
250+
return []
251+
if isinstance(value, str):
252+
return [value] if value else []
253+
return [item for item in value if item]
254+
255+
237256
def filtered_model_entries(
238-
ctx: GatewayContext, installed_only: bool, language: str
257+
ctx: GatewayContext,
258+
installed_only: bool = False,
259+
language: str | list[str] | None = None,
260+
family: str | list[str] | None = None,
261+
engine: str | list[str] | None = None,
262+
max_size: str = "",
263+
recommended_only: bool = False,
239264
) -> list[AdminModelEntry]:
265+
"""Filter the catalog. Within a multi-select dimension match is OR; across
266+
dimensions match is AND. Languages use model_covers (empty codes = match all).
267+
"""
268+
languages = _as_str_list(language)
269+
families = _as_str_list(family)
270+
engines = _as_str_list(engine)
271+
size_cap = SIZE_FILTER_CAPS.get(max_size.strip().lower()) if max_size else None
272+
240273
entries = model_entries(ctx)
241274
if installed_only:
242275
entries = [entry for entry in entries if entry.state == "installed"]
243-
if language:
244-
entries = [entry for entry in entries if model_covers(entry, language)]
276+
if languages:
277+
entries = [
278+
entry for entry in entries if any(model_covers(entry, code) for code in languages)
279+
]
280+
if families:
281+
allowed = set(families)
282+
entries = [entry for entry in entries if entry.family in allowed]
283+
if engines:
284+
allowed_engines = set(engines)
285+
entries = [entry for entry in entries if entry.engine in allowed_engines]
286+
if size_cap is not None:
287+
entries = [entry for entry in entries if entry.size_bytes <= size_cap]
288+
if recommended_only:
289+
entries = [entry for entry in entries if entry.recommended]
245290
return entries
246291

247292

app/catalog.py

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def _whisper_cpp(
6767
description: str = "OpenAI Whisper converted for the standalone whisper.cpp engine.",
6868
source: str = "whisper.cpp",
6969
language_codes: tuple[str, ...] = (),
70+
license_name: str = "See model source",
7071
) -> CatalogModel:
7172
return CatalogModel(
7273
id=f"{ENGINE_WHISPER_CPP}:{key}",
@@ -83,6 +84,7 @@ def _whisper_cpp(
8384
description=description,
8485
source=source,
8586
language_codes=language_codes or _whisper_language_codes(languages),
87+
license_name=license_name,
8688
)
8789

8890

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

421424

425+
def catalog_source_url(model: CatalogModel) -> str | None:
426+
"""Best public page for a catalog entry (Hugging Face repo or project site).
427+
428+
Prefers a browsable page over a raw .tar/.bin download blob.
429+
"""
430+
if model.huggingface_repo:
431+
return f"https://huggingface.co/{model.huggingface_repo}"
432+
if model.download_url and "huggingface.co/" in model.download_url:
433+
url = model.download_url
434+
if "/resolve/" in url:
435+
head, _, _ = url.partition("/resolve/")
436+
return head
437+
return url
438+
# Release/tag pages are more specific than a project root (e.g. SenseVoice /
439+
# Parakeet v3 ship only as sherpa-onnx GitHub release assets).
440+
release = _github_release_page(model.archive_url)
441+
if release:
442+
return release
443+
# Label-specific pages (Handy, Breeze, …) before generic engine fallbacks —
444+
# otherwise Handy builds incorrectly link to whisper.cpp's GitHub.
445+
labeled = _SOURCE_LABEL_URLS.get(model.source)
446+
if labeled:
447+
return labeled
448+
project = _ENGINE_SOURCE_URLS.get(model.engine)
449+
if project:
450+
return project
451+
if model.download_url:
452+
return model.download_url
453+
if model.archive_url:
454+
return model.archive_url
455+
return None
456+
457+
458+
def _github_release_page(archive_url: str | None) -> str | None:
459+
"""Turn a GitHub release asset URL into the browsable release/tag page."""
460+
if not archive_url or "/releases/download/" not in archive_url:
461+
return None
462+
head, _, rest = archive_url.partition("/releases/download/")
463+
if not head.startswith("https://github.com/") or not rest:
464+
return None
465+
tag = rest.split("/", maxsplit=1)[0]
466+
return f"{head}/releases/tag/{tag}" if tag else None
467+
468+
469+
_ENGINE_SOURCE_URLS = {
470+
ENGINE_WHISPER_CPP: "https://github.com/ggml-org/whisper.cpp",
471+
ENGINE_WHISPERKIT: "https://github.com/argmaxinc/WhisperKit",
472+
ENGINE_FASTER_WHISPER: "https://github.com/SYSTRAN/faster-whisper",
473+
ENGINE_MOONSHINE: "https://github.com/moonshine-ai/moonshine",
474+
ENGINE_SHERPA_ONNX: "https://github.com/k2-fsa/sherpa-onnx",
475+
ENGINE_MLX_AUDIO: "https://github.com/Blaizzy/mlx-audio",
476+
}
477+
478+
_SOURCE_LABEL_URLS = {
479+
"whisper.cpp": "https://github.com/ggml-org/whisper.cpp",
480+
"faster-whisper": "https://github.com/SYSTRAN/faster-whisper",
481+
"WhisperKit": "https://github.com/argmaxinc/WhisperKit",
482+
"Moonshine Voice": "https://github.com/moonshine-ai/moonshine",
483+
"sherpa-onnx": "https://github.com/k2-fsa/sherpa-onnx",
484+
"MLX Audio": "https://github.com/Blaizzy/mlx-audio",
485+
# Hosted on Handy's CDN; the product page is the right "source", not whisper.cpp.
486+
"Handy-compatible": "https://handy.computer",
487+
"Breeze ASR": "https://huggingface.co/MediaTek-Research/Breeze-ASR-25",
488+
}
489+
490+
422491
def language_names(codes: tuple[str, ...]) -> list[str]:
423492
"""Human-readable names for a model's languages, in the order declared."""
424493
return [LANGUAGE_NAMES.get(code, code) for code in codes]
@@ -577,9 +646,8 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
577646
language_codes=("en",),
578647
family="Parakeet TDT",
579648
description=(
580-
"The English-only Parakeet. v3 traded English accuracy for 25-language coverage, so "
581-
"this earlier release still transcribes English more accurately than the v3 entry "
582-
"above at the same speed."
649+
"The English-only Parakeet. v3 trades some English accuracy for 25-language coverage, "
650+
"so this earlier release still transcribes English more accurately at the same speed."
583651
),
584652
license_name="CC BY 4.0",
585653
),
@@ -657,9 +725,8 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
657725
language_codes=("en",),
658726
family="Zipformer",
659727
description=(
660-
"A small streaming-capable zipformer transducer. Unlike the other sherpa-onnx "
661-
"models above, this one decodes incrementally over /v1/stream with real partial "
662-
"results, independent of Moonshine."
728+
"A small streaming-capable zipformer transducer. Unlike most batch sherpa-onnx "
729+
"models, this one decodes incrementally with real partial results while you speak."
663730
),
664731
license_name="Apache 2.0",
665732
supports_streaming=True,
@@ -803,8 +870,8 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
803870
repository="mlx-community/parakeet-tdt-0.6b-v2",
804871
family="Parakeet TDT / MLX",
805872
description=(
806-
"The English-only Parakeet on Apple silicon. More accurate on English than the v3 "
807-
"entry above, which spends capacity on 24 other languages."
873+
"The English-only Parakeet on Apple silicon. More accurate on English than the "
874+
"multilingual v3 build, which spends capacity on 24 other languages."
808875
),
809876
license_name="CC BY 4.0",
810877
language_codes=("en",),
@@ -980,8 +1047,13 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
9801047
"Accurate · compact",
9811048
8,
9821049
download_url="https://blob.handy.computer/whisper-medium-q4_1.bin",
983-
description="Handy's compact Whisper Medium build, usable without the Handy app.",
1050+
description=(
1051+
"Handy's compact quantized Whisper Medium (Q4). Same whisper.cpp runtime; weights "
1052+
"are hosted on Handy's CDN and work without the Handy app."
1053+
),
9841054
source="Handy-compatible",
1055+
# MIT Whisper weights; Handy redistributes the quant.
1056+
# License string left generic so the UI links through to Handy rather than inventing one.
9851057
),
9861058
_whisper_cpp(
9871059
"ggml-large-v3-turbo.bin",
@@ -999,7 +1071,10 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
9991071
"Most accurate · compact",
10001072
16,
10011073
download_url="https://blob.handy.computer/ggml-large-v3-q5_0.bin",
1002-
description="Quantized Whisper Large v3 from Handy's standalone model catalog.",
1074+
description=(
1075+
"Quantized Whisper Large v3 (Q5) from Handy's model catalog. Larger and more accurate "
1076+
"than Medium Q4; still runs through the local whisper.cpp engine."
1077+
),
10031078
source="Handy-compatible",
10041079
),
10051080
_whisper_cpp(
@@ -1011,9 +1086,14 @@ def language_names(codes: tuple[str, ...]) -> list[str]:
10111086
16,
10121087
download_url="https://blob.handy.computer/breeze-asr-q5_k.bin",
10131088
family="Breeze ASR",
1014-
description="Whisper variant tuned for Taiwanese Mandarin and code-switching.",
1015-
source="Handy-compatible",
1089+
description=(
1090+
"MediaTek Breeze-ASR (Whisper Large v2 fine-tune) quantized to Q5 for whisper.cpp. "
1091+
"Tuned for Taiwanese Mandarin and Mandarin–English code-switching; "
1092+
"weights redistributed via Handy's CDN."
1093+
),
1094+
source="Breeze ASR",
10161095
language_codes=("zh", "en"),
1096+
license_name="Apache 2.0",
10171097
),
10181098
_whisper_cpp(
10191099
"ggml-large-v3.bin", "whisper.cpp Large v3", 3 * GB, "Multilingual", "Most accurate", 24

app/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def serve() -> None:
3636
host = settings.bind_host
3737
token_path = settings.token_file_display
3838
token_source = "(from VOCAPHONE_TOKEN)" if _token_from_env() else token_path
39-
print(f"vocaphone gateway listening on {format_host_port(host, settings.port)}")
39+
print(f"VocaGateway listening on {format_host_port(host, settings.port)}")
4040
print(f"WebUI (this host): {local_webui_url(host, settings.port)}")
4141
if host in WILDCARD_BIND_HOSTS:
4242
print("Network access: use this host's LAN or Tailscale IP with the same port")

app/fragments/engine.py

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from html import escape
44

5+
from app.config import format_host_port, local_webui_url
56
from app.schemas import EngineStatus
67
from app.system import engine_requirement
78

@@ -18,20 +19,20 @@
1819
}
1920

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

3738

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

4445

45-
def _engine_status(engine: EngineStatus, *, oob: bool = False) -> str:
46-
"""The engine indicator in the header: a status dot and the engine's name."""
46+
def _engine_status(
47+
engine: EngineStatus,
48+
*,
49+
bind_host: str = "0.0.0.0",
50+
port: int = 8765,
51+
oob: bool = False,
52+
) -> str:
53+
"""Header control: active model, network details on hover, opens Models on click."""
4754
classes = "engine-status ready" if engine.ready else "engine-status"
4855
dot = "ok" if engine.ready else "warn"
4956
label = escape(engine.name or engine.id)
57+
listener = escape(format_host_port(bind_host, port))
58+
local_url = escape(local_webui_url(bind_host, port))
59+
ready_label = "Ready" if engine.ready else "Not ready"
5060
swap_oob = ' hx-swap-oob="true"' if oob else ""
61+
# Network addresses live in the hover card so Overview stays uncluttered.
5162
return (
52-
f'<div id="engine-pill" class="{classes}"{swap_oob}'
63+
f'<button type="button" id="engine-pill" class="{classes}"{swap_oob}'
64+
f' data-open-tab="models"'
65+
f' aria-label="Speech model {label}, {ready_label}. '
66+
f'Listener {listener}. WebUI {local_url}. Opens Models."'
5367
f' hx-get="/ui/partials/engine-pill" hx-trigger="every 5s" hx-swap="outerHTML">'
54-
f'<span class="dot {dot}" aria-hidden="true"></span><span>{label}</span></div>'
68+
f'<span class="dot {dot}" aria-hidden="true"></span>'
69+
f"<span>{label}</span>"
70+
f'<span class="engine-status-hint" aria-hidden="true">Models</span>'
71+
f'<span class="engine-popover" role="tooltip">'
72+
f'<span class="engine-popover-card">'
73+
f'<span class="engine-popover-title">{ready_label}</span>'
74+
f'<span class="engine-popover-row"><span>Listener</span>'
75+
f"<code>{listener}</code></span>"
76+
f'<span class="engine-popover-row"><span>This host</span>'
77+
f"<code>{local_url}</code></span>"
78+
f'<span class="engine-popover-hint">Click to open Models</span>'
79+
f"</span></span></button>"
5580
)
5681

5782

58-
def engine_pill_fragment(engine: EngineStatus) -> str:
59-
return _engine_status(engine)
83+
def engine_pill_fragment(
84+
engine: EngineStatus, *, bind_host: str = "0.0.0.0", port: int = 8765
85+
) -> str:
86+
return _engine_status(engine, bind_host=bind_host, port=port)
6087

6188

62-
def engine_pill_oob(engine: EngineStatus) -> str:
63-
return _engine_status(engine, oob=True)
89+
def engine_pill_oob(engine: EngineStatus, *, bind_host: str = "0.0.0.0", port: int = 8765) -> str:
90+
return _engine_status(engine, bind_host=bind_host, port=port, oob=True)
6491

6592

66-
def engine_update_fragment(engine: EngineStatus, message: str) -> str:
93+
def engine_update_fragment(
94+
engine: EngineStatus,
95+
message: str,
96+
*,
97+
bind_host: str = "0.0.0.0",
98+
port: int = 8765,
99+
) -> str:
67100
css = "ok" if engine.ready else "missing"
68101
return (
69102
f'<span class="badge {css}">{escape(engine.name or engine.id)}'
70103
f"{' ready' if engine.ready else ' not ready'}</span> "
71-
f"{escape(message)}{engine_pill_oob(engine)}"
104+
f"{escape(message)}"
105+
f"{engine_pill_oob(engine, bind_host=bind_host, port=port)}"
72106
)

0 commit comments

Comments
 (0)