Repository navigation
feat(stt): manage speech models in a table with download, select and remove - #17624
Pearcekieser wants to merge 1 commit into
Conversation
cdeb2c1 to
a9a4d59
Compare
Design Review (Opus 5.5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS No design-level blocker: removal goes through catalog names only, refuses every model that something is still reading, and rendered evidence is attached. Notes
[DESIGN-REVIEWED] 384569d Description read[DESCRIPTION-READ] d43a629e3e8ceac8c1bb559ec7c690eaebe5e2826afd4c274b5563e292025080 That sha256 names everything this verdict read: the PR description, plus the 1 evidence file(s) the workflow collected from it and handed this review. It covers that evidence because the media strip replaces every attachment URL with the same placeholder, so an edit swapping one attachment for another would leave the description's captured text identical while what this review judged changed. Recompute it with |
First Principles Review (Opus 5.5, fork) — ✅ PASSPremise-level review of First-Principles-Verdict: PASS Nothing blocks. Every item serves the frozen Goal. The one cost worth weighing is how far the model-claim mechanism spreads. Notes
What this change shipsInventory (9 items) — 8 justifiedIntent: let a user see, download, select and delete local speech models from Settings instead of the shell — ADDITION.
[FIRST-PRINCIPLES-REVIEWED] 384569d Description read[DESCRIPTION-READ] ad44921e03f77f72bb4c797146f1562322541efe6340a26655683d75cad7a10d That sha256 names the PR description this verdict read. Recompute it with |
UX Review (Opus 5.5, fork) — ✅ PASSUX-level review of UX-Verdict: PASS No blockers. The screenshots cover every control and state the diff adds, but no first-time reader has looked at them, because this fork lane runs a single pass. Pushing the branch to this repository runs the blind read. Notes
[UX-REVIEWED] 384569d Description read[DESCRIPTION-READ] bfa0e29e5f0f47013c832e7d3d55f14e9f2fc54226348036f0b7ae1ea950e0a9 That sha256 names everything this verdict read: the PR description, plus the 22 evidence file(s) the workflow collected from it and handed this review. It covers that evidence because the media strip replaces every attachment URL with the same placeholder, so an edit swapping one attachment for another would leave the description's captured text identical while what this review judged changed. Recompute it with |
Opus 5.5 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. Notes:
[OPUS-REVIEWED] 384569d |
GPT 6.1 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
|
span=ec7d7961be59
|
|
span=6b1de615f2e3
|
|
span=b265d3e80576
|
a9a4d59 to
fa3bd9c
Compare
|
span=12977fcca565
|
|
span=86e05a4c8cfc
|
|
span=9eebae47fd57
|
|
span=2c8aa6fc69d9
|
|
span=1e4cecfe0caf
|
fa3bd9c to
2865c06
Compare
|
Rebased onto main 561b345 as 1c780aa to clear a conflict in |
|
Backend Lint & Type Check (3.12) failed on 1c780aa because of a flake8 error on main. Nothing in this change caused it. flake8 reports Job: https://github.com/kirodotdev/KiroCrew/actions/runs/40460056895/job/122047892437 A retrigger cannot pass until #24767 lands, so I will not push before then. Once it does, I will rebase onto main. Opus 5.5 has no findings on 1c780aa, and UX passed. |
|
Rebased onto main f3a7d42 as 826debe to pick up #24767, which fixes the flake8 F821 in I also edited the description for the UX note on attachments 04 and 06. My disposition said I would re-capture them, but I captioned them instead. They are the deliberate "before" frames for the narrow-layout fix, each sits next to its "after" frame from this head, and a fresh capture would only reproduce the "after". Each one now has a line saying it is from an earlier head and what it shows. |
|
Backend Tests (3.12, 2) failed on 826debe because of another stale strict xfail on main. Nothing in this change caused it.
Job: https://github.com/kirodotdev/KiroCrew/actions/runs/40485411971/job/122144664292 A retrigger cannot pass until #24774 lands, so I will not push before then. Once it does, I will rebase onto main. |
|
Rebased onto main 6632fd7 as 6c7daa5 to pick up #24774. That change drops the stale strict xfail on |
|
|
Rebased onto main c55d16f as 3ec5ff8 to clear a conflict with #24769 in This push also re-runs Cross-Platform Portability and Automated Rule Check, which were cancelled on 6c7daa5 after every other check had passed. |
|
Rebased onto main 1b91dd9 as 87f07b8 to clear another conflict in This push also re-runs the checks that did not settle on 3ec5ff8. GPT 6.1 and Opus 5.5 ended "review incomplete", Security Scope Review reported no leg, and Backend Tests (Windows) (7) hit the known timeout #23365. None of them was a finding. All three review lanes passed on 6c7daa5, which has the same code. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Problem / Motivation
Goal: In Settings > Voice > Speech-to-Text, a user can see every local speech model's size and state, and download, select or remove each one from one place.
The Model row is a dropdown. It shows the state of the selected model only, and a Download button appears only when that model is missing. A user cannot see which other models are on disk, cannot fetch a model without first selecting it, and cannot delete one. Models run from 78 MB (
tiny) to 1.6 GB (large-v3-turbo), so a user who tried the large one has to delete files under<data home>/models/whisper/by hand to get the space back. A transfer of an unselected model is also invisible: the panel shows progress only for the selected model.Why it matters
The space cost is real and the only remedy today is a shell command in the gateway's data directory. Without a list of what is installed, a user also cannot tell which models the next selection will download.
Not a goal
What changed (motivation → approach → change)
A user can now manage the speech models from Settings instead of the data directory, and the panel says what each model costs and whether it is on disk.
The new route
DELETE /api/stt/models/{name}insrc/kiro_crew/dashboard/handlers/core.pylooks the name up in the catalog, so an alias, a filename or a traversal is a 404 and never becomes a path.ModelStore.removeinsrc/kiro_crew/stt/models.pydeletes onlymodels_dir() / model.filename; every catalog filename is one plain component, which a test pins, so the delete cannot name another directory. A symlink at that path is unlinked as a link, never followed. It refuses with 409 and a code instead of changing anything else: the configured model, aliases included (stt_model_selected); a model that work still reads (stt_model_in_use): a live voice session keeps the model it started with after the user selects another, and a batch transcription fixes its model before the upload and transcode awaits. A removed file would make that work re-download it, or fail offline and lose the audio. Both take aModelClaimfromsrc/kiro_crew/stt/models.py(LocalSessionuntilfinishhas decoded orcancelruns,POST /api/stt/transcribefor the whole request,_transcribe_localfor the Slack path, and a meetings audio import from its config snapshot until the transcript is back, so a split import keeps its model across every segment; the transcribe route, the meetings import, Slack voice memos and channel attachments all take it throughtranscribe.local_model_claim, which claims only for thelocalprovider), andModelStore.removerefuses a claimed model in the same synchronous step as the unlink. The route also checks before releasing anything. Claims sit in a weak set, so a holder dropped without releasing cannot pin a model; the model resident in the recogniser (stt_model_in_use), matched on the residency key's full path and never the file name, because a custom model may sit in a subfolder under a catalog file name (sub/ggml-small.bin) and is then the user's own selected model; any model while the recogniser is loading one (stt_model_loading); and the model being downloaded (stt_model_downloading). A deselected model is often still resident, because only the idle sweep unloads it (600 s by default). Removing it is the table's main use, so the route first releases it with the newWhisperEngine.release_if_resident, under the load and decode locks the way idle eviction takes them, and re-reads the selection after that await;stt_model_in_usethen answers only a model something put back. The loading check reads the newWhisperEngine.loadinginsrc/kiro_crew/stt/engine.py, which covers the load lock and a build that outlived its timeout, because the resident key is set only when a load finishes. No await sits between the remaining checks and the unlink, so no load or download can start in the gap.SttModelTableinwebsite/src/pages/settings/SttSettings.tsxmarks the row the status endpoint resolves the configured model to, so an oldmediumconfig showslarge-v3-turboas Selected; the download prompt and the CPU-slowness warning follow the same resolved name, so amediumconfig on a CPU-only build now gets the warninglarge-v3-turboalways had. Every row has a state badge: Installed, Not downloaded, or progress. The old dropdown showed no line for an installed selected model, and the table keeps that, adding no reassurance line under it; the per-row Installed state is what lets a user see what is on disk, which is the point of the table. Each refusal code maps to a sentence that says what to do, shown under the row it was for. When the selected model is not on disk yet, the download prompt is a row under that model, not a line below the table. A failed download says in plain words that it stopped and to choose Download now again; the gateway's reason rides in the agent hand-off. The row button reads Select, the same word as the Selected badge. The selected row never offers Remove and adds no standing line explaining why; trying to remove the configured model through the API returns thestt_model_selectedsentence. At most two buttons per row. The table uses the sharedtable-stripedclass, and each model's rows carry that model's stripe, so a failure or refusal row stays on its model's band instead of shifting the stripes below it. Belowsmeach row stacks its parts (name and size, then the status badges, then the buttons) and the column header is hidden: at 320px the pane is 288px wide, and in German the "Nicht heruntergeladen" badge beside a "Jetzt herunterladen" button pushed the buttons 74px past the edge. The new client call isapi.sttDeleteModelinwebsite/src/api/client/voice.ts.website/scripts/settingsExtract.tsindexes the table as thestt.modelsetting, so the settings registry and its deep link are unchanged.Backwards compatibility
Compatible:
GET /api/stt/status,POST /api/stt/prepareand thestt.modelconfig key are unchanged; the new route only adds a way to delete a file the user could already delete by hand.Removes nothing: the Model dropdown is replaced by a table that writes the same
stt.modelkey, keeps the label, hint andconfigKey, and the registry entry forvoice.modelis byte-identical. The retiredmodel_optioncatalog key had no other reader.Tests
test/test_stt_model_delete.py, 40 tests: a model claimed by a batch transcription, or claimed during the release, is refusedstt_model_in_use, andModelStore.removerefuses a claimed model itself; the transcribe route and the local batch path hold the claim for the whole request and drop it after; a deselected model a live session still uses is refusedstt_model_in_usewithout being released, and removed once that session is cancelled; a session started during the release also refuses it; a finished or garbage-collected session does not pin its model; a deselected model still resident (the idle sweep has not run) is released and removed; one something put back during the release is refusedstt_model_in_use; one selected again during the release is refusedstt_model_selected; a non-resident model releases nothing;release_if_residentdrops only the named file and waits for a running decode; a selected, resident custom model atsub/ggml-small.binis neither released nor touched whensmallis removed, andrelease_if_residentleaves it loaded; removal of an installed unselected model and only its file; a second removal is a no-op; refusals for the configured model, a configured alias, the resident model, any model during a load, and the downloading model; a download of another model does not block; exact catalog names only, with alias, filename, traversal and case variants all 404 and untouched; an app claim is 403; the route is registered;WhisperEngine.loadingfor an idle engine, a held load lock and a load past its timeout; the store resets a stalereadystatus and removes a symlink without its target; every catalog path is one file directly in the models directory.test/test_meetings_audio_import.py, 3 new tests: a local-provider import holds its model claim through the duration probe and the split, for a split that succeeds and one that fails, and drops it after; a cloud-provider import claims nothing. Both local cases fail without the route's claim.website/src/test/SttSettings.modelTable.test.tsx, 17 tests: the shared striped-table class, and a notice row on its model's stripe with the stripes below it unshifted; every row's state; an alias selects the resolved row; no Remove on the selected row and no standing hint; the selected row offers Download when its model is missing; a failed download renders on its own full-width row with a plain sentence and keeps Download; a refusal renders under its row; at most two buttons per row; Select writesstt.model; Download fetches without selecting; one transfer holds every Download; Remove confirms with the size, deletes, and re-reads status; cancel deletes nothing; each 409 code shows its sentence.SttSettings.modelDownloadDisclosure,SttSettings.surface,SttSettings.decoder.ApiClient.refactor.surfacepins the new client key;destructiveConfirmrecords why the two confirm keys need no quoting.The loading refusal test fails with the
engine.loadingcheck removed.Catalog keys under
pages.settings.sttSettings.model_*are translated in all 12 shipped languages inwebsite/src/i18n/locales/, withen-XA.jsonregenerated and a translator note for the Select button inwebsite/src/i18n/en.context.json.src/kiro_crew/docs/configuration.md,docs/system-specs/modules/stt-streaming.md,docs/system-specs/modules/learn-cron-dashboard.mdanddocs/feature-map/README.mddescribe the route and the table.Manual verification
End to end in a disposable pod on an earlier head (before the deselected-model release, which the unit tests above cover), with sparse files of the exact catalog sizes planted under the pod's
models/whisper(no real model downloaded),stt.model=base:DELETE /api/stt/models/smallremoved: true, file gone, status showssmallnot presentremoved: falseDELETE /api/stt/models/basestt_model_selected, file keptmedium,ggml-small.bin,..%2Fconfig.jsonNot exercised in the pod:
stt_model_in_use,stt_model_loadingandstt_model_downloading, which need a real whisper load or a live transfer; the unit tests above cover them. A browser pass against the pod stopped at its first-run onboarding, so the screenshots come fromwebsite/capture/voice-settings.tsx(shippedSttSettings, API stubbed) in Chromium.Screenshots / video
The Custom model row, added by the rebase onto #14918. These frames come from main's
website/scripts/capture-stt-custom-model.mjs, which serves the real built SPA from this head with the API stubbed. The Custom row is always the last row of the table. It has no size, and while it is not selected its only action is Select, with no Download or Remove.Custom selected while its file is missing from the speech models folder. The row has no actions, the status reads "Model file problem" and the reason shows under the file name field:
Custom selected with its file in place, shown as Selected and Installed:
The frames come from
website/capture/voice-settings.tsx(shippedSttSettingswith the API stubbed). The wide frames are from an earlier head, before the table was striped; this head changes nothing else in the wide layout. The 320px, 390px and German 820px frames are from this head and show the stripes. The two refused-Remove frames are from this head too:website/capture/voice-settings.tsxtakes&refuse=stt_model_in_useto stub the 409, and they show the current text, "Voice input is still using this model. Try again when it finishes."At 320px in German, before and after (the GPT narrow-viewport finding). A check in the capture script measured every table button ending at 394px in a 320px window before, and inside the window after:
Before, from an earlier head, with the model name broken at its hyphens and the button label wrapping:
After, on this head:
German 320px idle, before and after, and German at 820px
Before, from an earlier head:
After, on this head:
Before, dark (main's dropdown):
After, dark:
After, a transfer of an unselected model running (main showed nothing for it):
After, Remove confirmation:
After, a refused Remove (stubbed 409
stt_model_in_use), on a row under that model, with the Ask the agent hand-off:After, a failed download on its row, at 390px:
After, the selected model not yet downloaded, with the download prompt on the row under it (German, 820px):
Failed download at full width, narrow table, light theme, and main during the same transfer
Related Issues
Closes #17602
Review items deferred from this PR, one issue each:
release_if_resident, now that claims protect running work.stt_model_loading, and rewords its "few seconds" sentence.Checklist
feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
Contributions are accepted under the project's Apache-2.0 license; see the Licensing section of CONTRIBUTING.md.