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
+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,