mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -07:00
feat(capture): gate global hotkey on dictation readiness checklist
Stops the "stuck pill" failure where pressing the chord with missing STT/LLM models triggers a recording that has nowhere to land. The hotkey now stays disarmed until every gate (models downloaded, Input Monitoring + Accessibility granted) is green; the empty-state checklist in CapturesTab surfaces each unmet gate with a one-click action and auto-arms the chord once everything turns green. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
868a40fb7e
commit
85a3e1363f
@@ -480,6 +480,11 @@ def get_llm_model_configs() -> list[ModelConfig]:
|
||||
return _get_qwen_llm_configs()
|
||||
|
||||
|
||||
def get_stt_model_configs() -> list[ModelConfig]:
|
||||
"""Return only STT (Whisper) model configs."""
|
||||
return _get_whisper_configs()
|
||||
|
||||
|
||||
# Lookup helpers — these replace the if/elif chains in main.py
|
||||
|
||||
|
||||
|
||||
@@ -427,6 +427,34 @@ class PersonalitySpeakRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ModelReadiness(BaseModel):
|
||||
"""Per-model entry in the dictation readiness checklist.
|
||||
|
||||
``model_name`` is the canonical id used by ``POST /models/download`` so the
|
||||
frontend can wire a one-click "Download" button without a second lookup.
|
||||
``size`` is the user's chosen variant (e.g. "turbo", "0.6B"); ``display_name``
|
||||
is what the checklist row should show ("Whisper Turbo").
|
||||
"""
|
||||
|
||||
ready: bool
|
||||
model_name: str
|
||||
display_name: str
|
||||
size: str
|
||||
size_mb: Optional[int] = None
|
||||
|
||||
|
||||
class CaptureReadinessResponse(BaseModel):
|
||||
"""Backend gates that must be green before the global hotkey will fire.
|
||||
|
||||
The frontend combines this with its own TCC permission checks (input
|
||||
monitoring, accessibility) into the full dictation readiness checklist.
|
||||
Hotkey-enabled is the user's intent toggle and lives outside this struct.
|
||||
"""
|
||||
|
||||
stt: ModelReadiness
|
||||
llm: ModelReadiness
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Response model for health check."""
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config, models
|
||||
from ..backends import get_llm_model_configs, get_stt_model_configs
|
||||
from ..backends.base import is_model_cached
|
||||
from ..database import Capture as DBCapture, get_db
|
||||
from ..services import captures as captures_service
|
||||
from ..services import settings as settings_service
|
||||
@@ -152,6 +154,52 @@ async def refine_capture_endpoint(
|
||||
return capture
|
||||
|
||||
|
||||
@router.get("/capture/readiness", response_model=models.CaptureReadinessResponse)
|
||||
async def capture_readiness_endpoint(db: Session = Depends(get_db)):
|
||||
"""Whether the STT and LLM models the user has selected are downloaded.
|
||||
|
||||
The frontend gates the global hotkey on this — pressing the chord with
|
||||
a missing model would otherwise produce a stuck "transcribing" pill that
|
||||
waits forever for a download to finish. Checks on-disk cache, not RAM
|
||||
load, so the answer survives backend restarts.
|
||||
"""
|
||||
saved = settings_service.get_capture_settings(db)
|
||||
|
||||
stt_cfg = next(
|
||||
(c for c in get_stt_model_configs() if c.model_size == saved.stt_model),
|
||||
None,
|
||||
)
|
||||
llm_cfg = next(
|
||||
(c for c in get_llm_model_configs() if c.model_size == saved.llm_model),
|
||||
None,
|
||||
)
|
||||
|
||||
if stt_cfg is None or llm_cfg is None:
|
||||
# Should be impossible — both fields are pattern-validated against
|
||||
# known sizes — but bail loudly rather than return half a response.
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"No model config for stt={saved.stt_model} or llm={saved.llm_model}",
|
||||
)
|
||||
|
||||
return models.CaptureReadinessResponse(
|
||||
stt=models.ModelReadiness(
|
||||
ready=is_model_cached(stt_cfg.hf_repo_id),
|
||||
model_name=stt_cfg.model_name,
|
||||
display_name=stt_cfg.display_name,
|
||||
size=stt_cfg.model_size,
|
||||
size_mb=stt_cfg.size_mb or None,
|
||||
),
|
||||
llm=models.ModelReadiness(
|
||||
ready=is_model_cached(llm_cfg.hf_repo_id),
|
||||
model_name=llm_cfg.model_name,
|
||||
display_name=llm_cfg.display_name,
|
||||
size=llm_cfg.model_size,
|
||||
size_mb=llm_cfg.size_mb or None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/captures/{capture_id}/retranscribe", response_model=models.CaptureResponse)
|
||||
async def retranscribe_capture_endpoint(
|
||||
capture_id: str,
|
||||
|
||||
Reference in New Issue
Block a user