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:
James Pine
2026-04-23 03:09:44 -07:00
co-authored by Claude Opus 4.7
parent 868a40fb7e
commit 85a3e1363f
10 changed files with 480 additions and 14 deletions
+5
View File
@@ -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
+28
View File
@@ -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."""
+48
View File
@@ -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,