Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
279456f
feat(whispering): redesign first-run onboarding as a two-column layout
braden-w Jun 21, 2026
ddb64ac
refactor(whispering): drop dead padding class from local model empty …
braden-w Jun 21, 2026
ba89e06
style(whispering): checkpoint onboarding container and empty-state tw…
braden-w Jun 21, 2026
6a25552
refactor(whispering): make first-run onboarding a single-column flow
braden-w Jun 21, 2026
8b75e83
polish(whispering): stack onboarding trust strip as single-column rows
braden-w Jun 21, 2026
5d2cfc6
feat(whispering): make first-run setup decisive instead of a service …
braden-w Jun 21, 2026
306056c
Merge remote-tracking branch 'origin/main' into polish/whispering-onb…
braden-w Jun 21, 2026
5b86496
refactor(whispering): extract TranscriptionSetup as the shared setup …
braden-w Jun 21, 2026
a1110e6
feat(whispering): universal first-run setup flow (flag-free)
braden-w Jun 21, 2026
bcbb825
refactor(whispering): collapse first-run setup indirection
braden-w Jun 21, 2026
38ea14b
refactor(whispering): narrow TranscriptionReadiness to its consumed f…
braden-w Jun 21, 2026
908da99
refactor(whispering): make the transcription service picker caller-owned
braden-w Jun 22, 2026
296ebc7
feat(whispering): add a first-run welcome step with the trust strip
braden-w Jun 22, 2026
6ddf97d
refactor(whispering): drop the dead class prop on TranscriptionRuntim…
braden-w Jun 22, 2026
18bfb66
refactor(whispering): give RecordingActionCard one controller, not ei…
braden-w Jun 22, 2026
7d33f1e
feat(whispering): first dictation uses the real recorder card
braden-w Jun 22, 2026
4d48649
refactor(whispering): make CapturePipeline a surface-driven single so…
braden-w Jun 22, 2026
3faecd2
refactor(whispering): hand the pipeline a device selector, not a surf…
braden-w Jun 22, 2026
158f33a
refactor(whispering): collapse the pipeline back to one component
braden-w Jun 22, 2026
5dd4655
refactor(whispering): render the first-run model step as a download hero
braden-w Jun 22, 2026
288e8dc
fix(whispering): widen the first-run welcome CTA to the trust cards
braden-w Jun 22, 2026
afa75a5
feat(whispering): lead the first-run engine step with an on-device/cl…
braden-w Jun 22, 2026
9239b96
feat(whispering): show the real transcript and audio in first dictation
braden-w Jun 22, 2026
efa3025
refactor(whispering): flat-list the settings model library, add a bar…
braden-w Jun 22, 2026
3f67452
feat(whispering): attach the first-run setup to its chosen location card
braden-w Jun 22, 2026
0866263
fix(whispering): make the model selector's missing-check reactive
braden-w Jun 22, 2026
c266851
fix(whispering): replace the engine caret with a crisp CSS triangle
braden-w Jun 22, 2026
9b7276a
refactor(whispering): share the recording result between home and fir…
braden-w Jun 22, 2026
f471a0a
refactor(whispering): collapse the model folder to one source of truth
braden-w Jun 23, 2026
b7fbb95
refactor(whispering): fold model completeness into the folder scan
braden-w Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 127 additions & 63 deletions apps/whispering/src-tauri/src/transcription/model_folder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ pub struct ModelEntry {
/// Whether the entry is a symlink (a "bring your own model" link). Display
/// only ("Your model (linked)"); it does not change how the entry loads.
pub linked: bool,
/// Whether this is a complete install. A catalog entry (its name matches a
/// model in the passed catalog) is checked against that model's expected
/// files at the 90% floor; a custom (non-catalog) entry has no expectation
/// and reads as complete.
pub complete: bool,
}

/// One file to download for a model. Mirrors the catalog shape: a Whisper model
Expand All @@ -82,11 +87,24 @@ pub struct ModelFileDownload {
pub size_bytes: f64,
}

/// One catalog model's expectation, passed by the webview (which owns the
/// catalog) so the scan can judge each entry's completeness in the same pass.
/// `filenames` empty means the entry is itself the file (Whisper), checked
/// against `expected_sizes[0]`; otherwise one filename per file inside the entry
/// directory, each checked against the aligned `expected_sizes`.
#[derive(Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CatalogModel {
/// The model's folder entry name, matched against scanned entry names.
pub entry_name: String,
pub filenames: Vec<String>,
pub expected_sizes: Vec<f64>,
}

/// One file's presence and completeness in a model entry, resolved through any
/// symlink. The webview supplies expected catalog sizes and reads back both the
/// stat'd `size` (for messaging) and the `complete` verdict (for installed /
/// truncated decisions). The completeness rule itself lives in Rust; see
/// `is_size_complete`.
/// stat'd `size` (for messaging) and the `complete` verdict. The completeness
/// rule itself lives in Rust; see `is_size_complete`.
#[derive(Clone, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ModelFileStatus {
Expand All @@ -101,11 +119,15 @@ pub struct ModelFileStatus {
/// List every selectable entry in the engine's models folder: model files
/// (.bin/.gguf/.ggml) for Whisper, directories for Parakeet and Moonshine, plus
/// symlinks to either. Hidden entries and in-flight `.partial` staging are
/// skipped. Returns an empty list when the folder does not exist yet.
/// skipped. Returns an empty list when the folder does not exist yet. Each
/// entry's `complete` verdict is judged against the matching catalog model in
/// one pass (the webview owns the catalog and passes it in); a custom entry,
/// which matches no catalog model, reads as complete.
#[tauri::command]
#[specta::specta]
pub fn list_model_entries(
engine: Engine,
catalog: Vec<CatalogModel>,
app_handle: AppHandle,
) -> Result<Vec<ModelEntry>, ModelFolderError> {
let models_dir = engine_models_path(&app_handle, engine)
Expand Down Expand Up @@ -134,68 +156,67 @@ pub fn list_model_entries(
Engine::Parakeet | Engine::Moonshine => file_type.is_dir() || linked,
};
if keep {
entries.push(ModelEntry { name, linked });
// Judge completeness against the matching catalog model, resolving
// through any symlink; a custom (non-catalog) entry has no
// expectation and reads as complete.
let complete = catalog
.iter()
.find(|model| model.entry_name == name)
.map(|model| {
entry_complete(
&models_dir.join(&name),
&model.filenames,
&model.expected_sizes,
)
})
.unwrap_or(true);
entries.push(ModelEntry {
name,
linked,
complete,
});
}
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}

fn has_whisper_extension(name: &str) -> bool {
std::path::Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| WHISPER_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
.unwrap_or(false)
}

/// Remove one entry from the engine's models folder. A symlinked entry removes
/// only the link, never its target; a real entry is removed outright. The name
/// must be a single folder entry, so this can never reach outside the folder.
/// Succeeds when the entry is already gone.
#[tauri::command]
#[specta::specta]
pub fn delete_model_entry(
engine: Engine,
name: String,
app_handle: AppHandle,
) -> Result<(), ModelFolderError> {
if !is_contained_entry_name(&name) {
return Err(ModelFolderError::InvalidEntryName {
message: format!("Model entry name must be a single models-folder entry, got: {name}"),
});
}
let path = engine_models_path(&app_handle, engine)
.map_err(|message| ModelFolderError::DeleteFailed { message })?
.join(&name);

let Ok(meta) = path.symlink_metadata() else {
// Already gone (or never existed): nothing to delete.
return Ok(());
};

let outcome = if meta.file_type().is_symlink() {
unlink_symlink(&path)
} else if meta.is_dir() {
std::fs::remove_dir_all(&path)
/// Whether an entry is a complete install: every expected file present and at
/// least the completeness floor of its catalog size, resolved through any
/// symlink. Empty `filenames` means the entry is itself the file (Whisper),
/// checked against `expected_sizes[0]`; otherwise one per file inside the
/// directory. Shares the `is_size_complete` floor with the download integrity
/// check, so the read verdict and the write verdict can never drift.
fn entry_complete(entry: &Path, filenames: &[String], expected_sizes: &[f64]) -> bool {
let sizes: Vec<Option<f64>> = if filenames.is_empty() {
vec![file_size(entry)]
} else {
std::fs::remove_file(&path)
filenames
.iter()
.map(|filename| {
if is_contained_entry_name(filename) {
file_size(&entry.join(filename))
} else {
None
}
})
.collect()
};
outcome.map_err(|e| ModelFolderError::DeleteFailed {
message: format!("Could not delete \"{name}\": {e}"),
})
if sizes.len() != expected_sizes.len() {
return false;
}
sizes
.iter()
.zip(expected_sizes)
.all(|(size, &expected)| matches!(size, Some(actual) if is_size_complete(*actual, expected)))
}

/// Resolve an entry **through any symlink** and report each expected file's size
/// and completeness verdict. The webview passes the expected catalog sizes (it
/// owns the catalog); the 90% completeness rule lives here next to the stat, so
/// "what counts as a complete file on disk" has one owner shared with the
/// download integrity check (`is_size_complete`). An empty `filenames` means the
/// entry is itself the file (Whisper) and returns one element checked against
/// `expected_sizes[0]`; otherwise one element per filename (directory engines),
/// each checked against the aligned `expected_sizes`. A dead link reports
/// `size: None, complete: false`, so a linked-but-broken model reads as not
/// installed.
/// Resolve one entry **through any symlink** and report each expected file's
/// stat'd size and completeness verdict. The list scan (`list_model_entries`)
/// folds completeness into one boolean per entry for the selector; this returns
/// the per-file sizes the transcribe pre-flight needs to message a truncated
/// download ("got 200MB, expected 488MB"). Both share the `is_size_complete`
/// floor. An empty `filenames` means the entry is itself the file (Whisper).
#[tauri::command]
#[specta::specta]
pub fn resolve_model_files(
Expand All @@ -214,8 +235,6 @@ pub fn resolve_model_files(
.map_err(|message| ModelFolderError::ReadFailed { message })?
.join(&name);

// Empty `filenames` => the entry itself is the file (Whisper); otherwise one
// file per name inside the entry directory.
let sizes: Vec<Option<f64>> = if filenames.is_empty() {
vec![file_size(&entry)]
} else {
Expand All @@ -231,8 +250,6 @@ pub fn resolve_model_files(
.collect()
};

// Pair each stat with its aligned expected size; a missing file (or a missing
// expectation) is never complete.
Ok(sizes
.into_iter()
.enumerate()
Expand All @@ -246,6 +263,51 @@ pub fn resolve_model_files(
.collect())
}

fn has_whisper_extension(name: &str) -> bool {
std::path::Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| WHISPER_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
.unwrap_or(false)
}

/// Remove one entry from the engine's models folder. A symlinked entry removes
/// only the link, never its target; a real entry is removed outright. The name
/// must be a single folder entry, so this can never reach outside the folder.
/// Succeeds when the entry is already gone.
#[tauri::command]
#[specta::specta]
pub fn delete_model_entry(
engine: Engine,
name: String,
app_handle: AppHandle,
) -> Result<(), ModelFolderError> {
if !is_contained_entry_name(&name) {
return Err(ModelFolderError::InvalidEntryName {
message: format!("Model entry name must be a single models-folder entry, got: {name}"),
});
}
let path = engine_models_path(&app_handle, engine)
.map_err(|message| ModelFolderError::DeleteFailed { message })?
.join(&name);

let Ok(meta) = path.symlink_metadata() else {
// Already gone (or never existed): nothing to delete.
return Ok(());
};

let outcome = if meta.file_type().is_symlink() {
unlink_symlink(&path)
} else if meta.is_dir() {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
outcome.map_err(|e| ModelFolderError::DeleteFailed {
message: format!("Could not delete \"{name}\": {e}"),
})
}

/// Clear whatever currently occupies a path before promoting onto it. Reads the
/// path's own type with `symlink_metadata` (never following a link), so a
/// colliding linked model is unlinked without touching its target, a stale real
Expand Down Expand Up @@ -441,7 +503,8 @@ async fn run_staged_download(
const COMPLETENESS_FLOOR: f64 = 0.9;

/// The single completeness rule, shared by the download integrity check
/// (`ensure_complete`) and the read-path verdict (`resolve_model_files`), so the
/// (`ensure_complete`) and the read-path verdicts (`entry_complete`,
/// `resolve_model_files`), so the
/// threshold has one owner instead of a copy on each side of the IPC boundary.
fn is_size_complete(received: f64, expected: f64) -> bool {
received >= expected * COMPLETENESS_FLOOR
Expand Down Expand Up @@ -529,8 +592,9 @@ mod tests {

#[test]
fn is_size_complete_is_the_single_floor_shared_by_both_paths() {
// The same rule `ensure_complete` (download) and `resolve_model_files`
// (read) both call, so the threshold can never drift between them.
// The same rule `ensure_complete` (download) and the read-path verdicts
// (`entry_complete`, `resolve_model_files`) all call, so the threshold can
// never drift between them.
assert!(is_size_complete(900.0, 1000.0));
assert!(!is_size_complete(899.0, 1000.0));
assert!(is_size_complete(1200.0, 1000.0));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,47 +11,50 @@
type LocalModelConfig,
modelEntryName,
} from '$lib/constants/local-models';
import { localModelDownloads } from '$lib/state/local-model-downloads.svelte';
import { deleteModelEntry } from '$lib/services/transcription/local-model-folder';
import type { ModelFolder } from '$lib/state/model-folder.svelte';
import {
announceModelDelete,
announceModelDownload,
} from './local-model-toasts';

let {
folder,
model,
value = $bindable(),
recommended = false,
onDiskChange,
}: {
/** The shared folder store, owned by the selector and passed to every row. */
folder: ModelFolder;
model: LocalModelConfig;
/** Bindable selected folder entry name for this engine. */
value: string;
/** Show the Recommended badge; the selector decides when it guides a choice. */
recommended?: boolean;
/** Re-scan the parent selector after this card changes the models folder. */
onDiskChange: () => void | Promise<void>;
} = $props();

// Shared per-model handle: the selector hero reads the same one, so a
// download started in either place shows its progress in both.
const download = $derived(localModelDownloads.get(model));

// Aliased so the template narrows the union per branch.
const modelState = $derived(download.state);
// Aliased so the template narrows the union per branch. The state comes from
// the shared store, so a download started anywhere shows its progress here.
const modelState = $derived(folder.stateOf(model));
const entryName = $derived(modelEntryName(model));
const isActive = $derived(value === entryName && modelState.type === 'ready');

async function downloadModel() {
const entryName = announceModelDownload(await download.download());
if (!entryName) return;
value = entryName;
await onDiskChange();
// The store re-scans itself on completion, so the shared selector reacts.
const downloaded = announceModelDownload(await folder.download(model));
if (!downloaded) return;
value = downloaded;
}

async function deleteModel() {
if (!announceModelDelete(await download.delete())) return;
if (
!announceModelDelete(
await deleteModelEntry({ engine: model.engine, name: entryName }),
)
)
return;
if (value === entryName) value = '';
await onDiskChange();
await folder.refresh();
}

async function activateModel() {
Expand All @@ -60,7 +63,7 @@
}

async function cancelDownload() {
await download.cancel();
await folder.cancel(model);
}
</script>

Expand Down
Loading