Skip to content

Commit 255f12f

Browse files
authored
feat(settings): delete downloaded transcription models (#120)
* feat(settings): delete downloaded transcription models * fix(settings): allow the macOS-only deletion offer on Linux
1 parent 8b587d9 commit 255f12f

6 files changed

Lines changed: 324 additions & 32 deletions

File tree

‎docs/features/README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,30 @@ The Cohere fixture inference check was run separately; see
179179
[dictation model windows](dictation.md#ongoing-jobs-and-output). Native menu
180180
interaction, installed-app switching, and Linux runtime behavior were not exercised.
181181

182+
```ts
183+
Settings picker > Downloaded model card // setup.delete-model; macOS
184+
├── Delete -> "Delete <size>?" -> Delete again
185+
│ ├── Success -> Model file, receipt, and partial download removed; card offers Download
186+
│ └── Download in progress -> Nothing removed; "Model could not be deleted." with reason
187+
├── Selected dictation model (any language) -> No Delete
188+
├── Preparing or Apple Speech model -> No Delete
189+
└── Language change, model choice, or closing the picker -> Confirmation cleared
190+
```
191+
192+
Deletion takes the shared model-directory download lock without waiting, so it
193+
cannot race a Settings, menu-bar, or local API download. The menu bar drops a
194+
deleted model because it reads installation state when it builds the menu. Linux
195+
Settings does not offer deletion: the service owns model files and has no delete
196+
request yet.
197+
198+
Sources: [shared picker](../../src/desktop_transcription_picker.rs),
199+
`remove_installed` in [transcription_models.rs](../../src/transcription_models.rs),
200+
and [app_window.rs](../../src/app_window.rs). Checks are
201+
`removing_a_model_deletes_its_artifacts_unless_a_download_is_active` (real files
202+
and lock) and `model_deletion_confirms_first_and_spares_the_dictation_model`
203+
(preview state, no disk access). Preview: `voice-control preview
204+
transcription-picker --model-state installed --confirm-model-deletion`.
205+
182206
**Known gap:** `is_installed` checks size, not checksum. Not every native-load
183207
path requires verification. Picker Active means selected/installed, not live
184208
worker readiness; a later worker-load failure does not roll back saved selection.

‎src/app_window.rs‎

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ use crate::application_catalog::InstalledApplication;
2222
use crate::commands::{CommandConfig, CommandInfo, CommandScope};
2323
use crate::desktop_activity::DesktopActivity;
2424
use crate::desktop_transcription_picker::{
25-
TranscriptionPickerDelegate, TranscriptionPickerModel, TranscriptionPickerProgress,
26-
TranscriptionPickerStatus, TranscriptionPickerView,
25+
ModelDeletion, TranscriptionPickerDelegate, TranscriptionPickerModel,
26+
TranscriptionPickerProgress, TranscriptionPickerStatus, TranscriptionPickerView,
2727
render_transcription_picker as render_shared_transcription_picker,
2828
transcription_selection_is_active,
2929
};
@@ -415,6 +415,7 @@ pub struct AppWindowPreview {
415415
pub pane: DeveloperPane,
416416
pub transcription_picker: Option<(String, PreviewModelState)>,
417417
pub onboarding: bool,
418+
pub confirm_model_deletion: bool,
418419
pub collapse_mode_processing: bool,
419420
pub open_transformation_picker: bool,
420421
pub select_global_mode: bool,
@@ -987,6 +988,8 @@ pub struct AppWindow {
987988
transcription_status: PreparationStatus,
988989
transcription_preparation: TranscriptionPreparation,
989990
transcription_preview_installed: Option<bool>,
991+
transcription_deletion: Option<TranscriptionModelId>,
992+
transcription_deletion_error: Option<String>,
990993
mode_editors: ModeEditors,
991994
voice_action_inputs: VoiceActionInputs,
992995
selected_mode: ModeSelection,
@@ -1436,6 +1439,23 @@ impl AppWindow {
14361439
) => Some(false),
14371440
Some(PreviewModelState::Actual) | None => preview_model_missing.then_some(false),
14381441
},
1442+
transcription_deletion: preview_picker
1443+
.filter(|_| {
1444+
preview
1445+
.as_ref()
1446+
.is_some_and(|preview| preview.confirm_model_deletion)
1447+
})
1448+
.and_then(|(language, _)| {
1449+
crate::transcription_models::choices_for_runtime(language)
1450+
.into_iter()
1451+
.map(|choice| choice.model)
1452+
.find(|model| {
1453+
matches!(model.runtime, ModelRuntime::Gguf(_))
1454+
&& model.id != settings.transcription.model
1455+
})
1456+
.map(|model| model.id)
1457+
}),
1458+
transcription_deletion_error: None,
14391459
mode_editors,
14401460
voice_action_inputs,
14411461
selected_mode: if preview
@@ -1974,6 +1994,54 @@ impl AppWindow {
19741994
fn clear_transcription_error(&mut self) {
19751995
self.transcription_preparation.set_error(None);
19761996
self.transcription_status = self.transcription_preparation.status();
1997+
self.transcription_deletion = None;
1998+
self.transcription_deletion_error = None;
1999+
}
2000+
2001+
/// Downloaded local models other than the dictation model can be deleted,
2002+
/// whatever language the picker shows, unless they are being prepared.
2003+
fn transcription_model_deletion(
2004+
&self,
2005+
model: &ModelDefinition,
2006+
installed: bool,
2007+
) -> ModelDeletion {
2008+
if !installed
2009+
|| !matches!(model.runtime, ModelRuntime::Gguf(_))
2010+
|| self.settings.transcription.model == model.id
2011+
|| self.transcription_status.model == Some(model.id)
2012+
{
2013+
ModelDeletion::Unavailable
2014+
} else if self.transcription_deletion == Some(model.id) {
2015+
ModelDeletion::Confirming
2016+
} else {
2017+
ModelDeletion::Offered
2018+
}
2019+
}
2020+
2021+
fn delete_transcription_model(&mut self, model: &'static ModelDefinition) {
2022+
self.transcription_deletion_error = None;
2023+
let language = self
2024+
.transcription_picker_language
2025+
.clone()
2026+
.unwrap_or_default();
2027+
let installed = self.transcription_model_installed(model, &language);
2028+
match self.transcription_model_deletion(model, installed) {
2029+
ModelDeletion::Unavailable => self.transcription_deletion = None,
2030+
ModelDeletion::Offered => self.transcription_deletion = Some(model.id),
2031+
ModelDeletion::Confirming => {
2032+
self.transcription_deletion = None;
2033+
if self.preview {
2034+
return;
2035+
}
2036+
match crate::transcription_models::remove_installed(model) {
2037+
Ok(()) => tracing::info!(model = ?model.id, "deleted transcription model"),
2038+
Err(error) => {
2039+
tracing::warn!(model = ?model.id, %error, "could not delete transcription model");
2040+
self.transcription_deletion_error = Some(error.to_string());
2041+
}
2042+
}
2043+
}
2044+
}
19772045
}
19782046

19792047
fn transcription_model_installed(&self, model: &ModelDefinition, language: &str) -> bool {
@@ -3168,13 +3236,17 @@ impl AppWindow {
31683236
} else if active {
31693237
TranscriptionPickerStatus::Active
31703238
} else {
3171-
TranscriptionPickerStatus::Available { installed }
3239+
TranscriptionPickerStatus::Available {
3240+
installed,
3241+
deletion: self.transcription_model_deletion(model, installed),
3242+
}
31723243
};
31733244
TranscriptionPickerModel { choice, status }
31743245
})
31753246
.collect();
31763247
TranscriptionPickerView {
31773248
error: self.transcription_status.error.clone(),
3249+
deletion_error: self.transcription_deletion_error.clone(),
31783250
language: selected_language.to_string(),
31793251
models,
31803252
}
@@ -7313,6 +7385,10 @@ impl TranscriptionPickerDelegate for AppWindow {
73137385
AppWindow::choose_transcription_model(self, definition(model), language, cx);
73147386
}
73157387

7388+
fn delete_transcription_model(&mut self, model: TranscriptionModelId, _cx: &mut Context<Self>) {
7389+
AppWindow::delete_transcription_model(self, definition(model));
7390+
}
7391+
73167392
fn dismiss_transcription_picker(&mut self, cx: &mut Context<Self>) {
73177393
AppWindow::dismiss_transcription_picker(self, cx);
73187394
}
@@ -8615,6 +8691,7 @@ mod tests {
86158691
pane: DeveloperPane::Modes,
86168692
transcription_picker: None,
86178693
onboarding: false,
8694+
confirm_model_deletion: false,
86188695
collapse_mode_processing: true,
86198696
open_transformation_picker: false,
86208697
select_global_mode: true,
@@ -8695,6 +8772,60 @@ mod tests {
86958772
Err(color_eyre::eyre::eyre!("fixture persistence failure"))
86968773
}
86978774

8775+
#[gpui::test]
8776+
fn model_deletion_confirms_first_and_spares_the_dictation_model(cx: &mut gpui::TestAppContext) {
8777+
let (view, cx) = cx.add_window_view(editor_fixture);
8778+
cx.update(|_, cx| {
8779+
view.update(cx, |view, _| {
8780+
view.transcription_preview_installed = Some(true);
8781+
view.transcription_picker_language = Some("en".into());
8782+
let current = definition(view.settings.transcription.model);
8783+
let other = crate::transcription_models::choices_for_runtime("en")
8784+
.into_iter()
8785+
.map(|choice| choice.model)
8786+
.find(|model| {
8787+
matches!(model.runtime, ModelRuntime::Gguf(_)) && model.id != current.id
8788+
})
8789+
.unwrap();
8790+
8791+
assert_eq!(
8792+
view.transcription_model_deletion(current, true),
8793+
ModelDeletion::Unavailable
8794+
);
8795+
assert_eq!(
8796+
view.transcription_model_deletion(other, false),
8797+
ModelDeletion::Unavailable
8798+
);
8799+
assert_eq!(
8800+
view.transcription_model_deletion(other, true),
8801+
ModelDeletion::Offered
8802+
);
8803+
8804+
view.delete_transcription_model(other);
8805+
assert_eq!(
8806+
view.transcription_model_deletion(other, true),
8807+
ModelDeletion::Confirming
8808+
);
8809+
view.clear_transcription_error();
8810+
assert_eq!(
8811+
view.transcription_model_deletion(other, true),
8812+
ModelDeletion::Offered
8813+
);
8814+
8815+
view.delete_transcription_model(other);
8816+
view.delete_transcription_model(other);
8817+
assert_eq!(view.transcription_deletion, None);
8818+
assert_eq!(view.transcription_deletion_error, None);
8819+
8820+
view.transcription_status.model = Some(other.id);
8821+
assert_eq!(
8822+
view.transcription_model_deletion(other, true),
8823+
ModelDeletion::Unavailable
8824+
);
8825+
});
8826+
});
8827+
}
8828+
86988829
#[gpui::test]
86998830
fn activity_log_is_only_read_when_an_observing_pane_needs_it(cx: &mut gpui::TestAppContext) {
87008831
let path = std::env::temp_dir().join(format!(

0 commit comments

Comments
 (0)