diff --git a/apps/whispering/src-tauri/src/transcription/model_folder.rs b/apps/whispering/src-tauri/src/transcription/model_folder.rs index 517490bb0d..3b6688d28e 100644 --- a/apps/whispering/src-tauri/src/transcription/model_folder.rs +++ b/apps/whispering/src-tauri/src/transcription/model_folder.rs @@ -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 @@ -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, + pub expected_sizes: Vec, +} + /// 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 { @@ -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, app_handle: AppHandle, ) -> Result, ModelFolderError> { let models_dir = engine_models_path(&app_handle, engine) @@ -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> = 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( @@ -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> = if filenames.is_empty() { vec![file_size(&entry)] } else { @@ -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() @@ -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 @@ -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 @@ -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)); diff --git a/apps/whispering/src/lib/components/settings/LocalModelDownloadCard.svelte b/apps/whispering/src/lib/components/settings/LocalModelDownloadCard.svelte index b4df3dfab1..dfce575f86 100644 --- a/apps/whispering/src/lib/components/settings/LocalModelDownloadCard.svelte +++ b/apps/whispering/src/lib/components/settings/LocalModelDownloadCard.svelte @@ -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; } = $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() { @@ -60,7 +63,7 @@ } async function cancelDownload() { - await download.cancel(); + await folder.cancel(model); } diff --git a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte index 1e9b5aec9c..3594be6116 100644 --- a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte +++ b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte @@ -2,14 +2,12 @@ import { Badge } from '@epicenter/ui/badge'; import { Button } from '@epicenter/ui/button'; import * as Card from '@epicenter/ui/card'; - import * as Collapsible from '@epicenter/ui/collapsible'; import * as Empty from '@epicenter/ui/empty'; import * as Field from '@epicenter/ui/field'; import * as Item from '@epicenter/ui/item'; import { Progress } from '@epicenter/ui/progress'; import { toast } from '@epicenter/ui/sonner'; import CheckIcon from '@lucide/svelte/icons/check'; - import ChevronDown from '@lucide/svelte/icons/chevron-down'; import Download from '@lucide/svelte/icons/download'; import FolderOpen from '@lucide/svelte/icons/folder-open'; import HardDriveDownload from '@lucide/svelte/icons/hard-drive-download'; @@ -24,13 +22,11 @@ import { deleteModelEntry, linkModelEntry, - listModelEntries, type ModelEntry, revealModelsFolder, } from '$lib/services/transcription/local-model-folder'; import { PROVIDERS } from '$lib/services/transcription/providers'; - import { localModelDownloads } from '$lib/state/local-model-downloads.svelte'; - import { tauri } from '#platform/tauri'; + import { modelFolder } from '$lib/state/model-folder.svelte'; import { announceModelDelete, announceModelDownload, @@ -38,11 +34,15 @@ import LocalModelDownloadCard from './LocalModelDownloadCard.svelte'; /** - * One happy path per engine: an empty-state hero that downloads the - * recommended model, or a summary row showing the active one. The full - * list (catalog download cards, custom folder entries, the folder help - * box) collapses behind "All models". The list is backed by the engine's - * models folder; the bindable value is the active entry's name. + * The engine's model library, in two shapes. `compact` (the first-run + * wizard) is a single hero: download the recommended model, or a one-line + * summary of the active one. Full (the settings page) is a flat list of + * every model, no disclosure: catalog rows you download/activate, any custom + * on-disk entries, and a bring-your-own footer. The list is backed by the + * engine's models folder; the bindable value is the active entry's name. + * + * `bare` drops the surrounding card chrome so a host (the first-run panel) + * can present the hero as attached content rather than a card-in-a-card. */ type LocalModelSelectorProps = { /** @@ -63,6 +63,16 @@ /** Optional footer content (download sources, naming notes) */ footer?: Snippet; + + /** Render the hero only, for the first-run wizard. See the type doc. */ + compact?: boolean; + + /** + * Drop the surrounding card chrome (border, header title/description) and + * render the body inline, so a host can present the hero as content + * attached to its own surface rather than a card-in-a-card. + */ + bare?: boolean; }; let { @@ -71,24 +81,27 @@ description, value = $bindable(), footer, + compact = false, + bare = false, }: LocalModelSelectorProps = $props(); const engine = $derived(models[0].engine); const modelKind = $derived(PROVIDERS[engine].modelKind); - /** Folder entry names the catalog cards already represent. */ - const catalogNames = $derived(new Set(models.map(modelEntryName))); - - let entries = $state(null); + // The one shared folder store for this engine: the single source of disk state + // (the scan) and in-flight downloads. Every view (this selector, its hero, each + // catalog row) reads it, so a download started anywhere updates them all + // reactively; nothing here keeps a private scan that could go stale. + const folder = $derived(modelFolder(models)); - const customEntries = $derived( - (entries ?? []).filter((entry) => !catalogNames.has(entry.name)), - ); + // Re-scan on mount and when the engine changes. The store persists across + // mounts, so an explicit refresh here catches a folder that changed while it + // was unmounted; window focus catches changes made while mounted. + $effect(() => { + folder.refresh(); + }); - // The active selection vanished from the folder (deleted or renamed). - const isSelectionMissing = $derived( - !!value && entries !== null && !entries.some((e) => e.name === value), - ); + const customEntries = $derived(folder.customEntries()); /** The catalog model behind the active entry, when it is a catalog one. */ const activeCatalogModel = $derived( @@ -99,52 +112,30 @@ customEntries.find((entry) => entry.name === value) ?? null, ); + // "Missing" means nothing in the folder backs the active selection. One truth: + // the store's scan, which is global and reactive, so a model downloaded after + // the user navigated away no longer reads as missing and needs no special-case. + const isSelectionMissing = $derived( + !!value && folder.loaded && !folder.present(value), + ); + /** The engine's default download; the hero builds its action around it. */ const recommended = $derived(RECOMMENDED_MODELS[engine]); - const recommendedDownload = $derived(localModelDownloads.get(recommended)); - - // Aliased so the template narrows the union per branch. Shared with the - // catalog row for the same model, so a download started here shows its - // progress there too. - const recommendedState = $derived(recommendedDownload.state); - - /** Whether the full list behind "All models" is expanded. */ - let allModelsOpen = $state(false); - // Plain variable, not $state: refreshEntries runs inside the rescan - // effect, and a reactive read here would make the effect track entries - // and re-run on its own assignment. - let hasDecidedInitialOpen = false; - - async function refreshEntries() { - if (!tauri) return; - entries = await listModelEntries(engine); - // The folder is user-editable truth, so the catalog handles re-check - // disk on the same signal that rescans the folder. Await the disk-stat - // so `isInstalled` (and the "Downloaded" badge it drives) is settled - // before the listing renders, instead of racing the next render. - await Promise.all(models.map((model) => localModelDownloads.get(model).refresh())); - // A user who already brought their own model gets the list, not a - // download pitch: when nothing is active and the first scan finds - // custom entries, start with the list open instead of the hero. - if (!hasDecidedInitialOpen) { - hasDecidedInitialOpen = true; - if (!value && customEntries.length > 0) allModelsOpen = true; - } - } + // Aliased so the template narrows the union per branch. Shared with the catalog + // row for the same model, so a download started here shows its progress there. + const recommendedState = $derived(folder.stateOf(recommended)); async function downloadRecommendedModel() { - const entryName = announceModelDownload(await recommendedDownload.download()); - if (!entryName) return; - // Rescan before selecting so the new entry is already in the list when - // `value` flips, instead of flashing "Selected model is missing" for the - // duration of the rescan. - await refreshEntries(); - value = entryName; + // The store re-scans itself on completion, so `value` lands on a present + // entry instead of flashing "Selected model is missing". + const downloaded = announceModelDownload(await folder.download(recommended)); + if (!downloaded) return; + value = downloaded; } async function cancelRecommendedDownload() { - await recommendedDownload.cancel(); + await folder.cancel(recommended); } /** Point the engine's selection at an on-disk entry by name. */ @@ -153,13 +144,6 @@ toast.success('Model activated'); } - // Rescan on mount and when the engine changes. Selection changes do not - // change disk; download/delete handlers refresh after they change the folder. - $effect(() => { - void engine; - refreshEntries(); - }); - async function openModelsFolder() { const { error } = await revealModelsFolder(engine); if (error) { @@ -215,7 +199,7 @@ } // Rescan before selecting so the new link is already in the list when // `value` flips (no transient "Selected model is missing" flash). - await refreshEntries(); + await folder.refresh(); value = entryName; toast.success('Model linked', { description: `${entryName} now points to your file. Deleting it later removes only the link.`, @@ -226,18 +210,27 @@ if (!announceModelDelete(await deleteModelEntry({ engine, name: entry.name }))) return; if (value === entry.name) value = ''; - await refreshEntries(); + await folder.refresh(); } - - - - - {title} - {description} - - + + +{#snippet body()} + {#if isSelectionMissing} +
+

+ Selected model is missing +

+

+ "{value}" is no longer in the models folder. Download it again, or add + your own and activate it. +

+
+ {/if} + + {#if compact} + {#if value && !isSelectionMissing} @@ -256,24 +249,16 @@ Active - - {:else if !value && customEntries.length === 0} - + {:else if !value} + No local model installed - Runs on this device — private, offline, and free. Download the - recommended model to start transcribing. + Download the recommended model to start transcribing on this device. {#if recommendedState.type === 'downloading'} @@ -299,112 +284,101 @@ {:else} {/if} {/if} - - {#if isSelectionMissing} -
-

- Selected model is missing -

-

- "{value}" is no longer in the models folder. Pick another model under - All models, or add yours back and activate it. -

-
- {/if} - - - + {#each models as model (model.id)} + 1 && model.id === recommended.id} + /> + {/each} + + {#each customEntries as entry (entry.name)} + {@const isActive = value === entry.name} +
- All models ({models.length + customEntries.length}) - - - - {#each models as model (model.id)} - 1 && model.id === recommended.id} - onDiskChange={refreshEntries} - /> - {/each} - - {#each customEntries as entry (entry.name)} - {@const isActive = value === entry.name} -
-
-
- {entry.name} - {#if isActive} - Active - {/if} -
-
- {entry.linked ? 'Your model (linked)' : 'Your model'} -
-
- -
- {#if isActive} - - {:else} - - {/if} - -
+
+
+ {entry.name} + {#if isActive} + Active + {/if} +
+
+ {entry.linked ? 'Your model (linked)' : 'Your model'}
- {/each} +
-
- - Have your own model? Link a {modelKind === 'directory' - ? 'model directory' - : 'model file (.bin, .gguf, or .ggml)'} from anywhere on disk and it - appears in this list, without copying a second copy. Or drop one into - the models folder yourself. - -
- - -
- {#if footer} - {@render footer()} {/if} +
- - - - +
+ {/each} + +
+ + Have your own model? Link a {modelKind === 'directory' + ? 'model directory' + : 'model file (.bin, .gguf, or .ggml)'} from anywhere on disk and it + appears in this list, without copying a second copy. Or drop one into the + models folder yourself. + +
+ + +
+ {#if footer} + {@render footer()} + {/if} +
+ {/if} +{/snippet} + +{#if bare} +
+ {@render body()} +
+{:else} + + + {title} + {description} + + + {@render body()} + + +{/if} diff --git a/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte b/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte index f246c17b1c..3b4ce99727 100644 --- a/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte +++ b/apps/whispering/src/lib/components/settings/TranscriptionRuntimeConfig.svelte @@ -1,3 +1,10 @@ + - - settings.get('transcription.service'), - (selected) => - settings.set('transcription.service', selected)} - /> - + {#if isSelectedServiceUnavailable && selectedTranscriptionProvider} Desktop-only service selected @@ -308,6 +305,8 @@ {:else if settings.get('transcription.service') === 'whispercpp'}
+ !locations || locations.includes(location); + const localServices = $derived( - tauri + tauri && allows('local') ? TRANSCRIPTION_PROVIDERS.filter((service) => service.location === 'local') : [], ); const cloudServices = $derived( - TRANSCRIPTION_PROVIDERS.filter((service) => service.location === 'cloud'), + allows('cloud') + ? TRANSCRIPTION_PROVIDERS.filter((service) => service.location === 'cloud') + : [], ); const selfHostedServices = $derived( - TRANSCRIPTION_PROVIDERS.filter( - (service) => service.location === 'self-hosted', - ), + allows('self-hosted') + ? TRANSCRIPTION_PROVIDERS.filter( + (service) => service.location === 'self-hosted', + ) + : [], ); const selectedService = $derived( diff --git a/apps/whispering/src/lib/components/settings/local-model-toasts.ts b/apps/whispering/src/lib/components/settings/local-model-toasts.ts index 3021ca9f88..91542b6a0f 100644 --- a/apps/whispering/src/lib/components/settings/local-model-toasts.ts +++ b/apps/whispering/src/lib/components/settings/local-model-toasts.ts @@ -1,7 +1,7 @@ import { toast } from '@epicenter/ui/sonner'; import type { Result } from 'wellcrafted/result'; import type { ModelFolderError } from '$lib/services/transcription/local-model-folder'; -import type { ModelDownloadResult } from '$lib/state/local-model-downloads.svelte'; +import type { ModelDownloadResult } from '$lib/state/model-folder.svelte'; /** * Toast the outcome of a catalog download and return the folder entry name to diff --git a/apps/whispering/src/lib/services/transcription/local-model-folder.ts b/apps/whispering/src/lib/services/transcription/local-model-folder.ts index 5db999a562..9691021454 100644 --- a/apps/whispering/src/lib/services/transcription/local-model-folder.ts +++ b/apps/whispering/src/lib/services/transcription/local-model-folder.ts @@ -36,13 +36,28 @@ export type { ModelEntry, ModelFolderError } from '$lib/tauri/commands'; type Engine = LocalModelConfig['engine']; /** - * List every selectable entry in the engine's models folder. Rust applies the - * per-engine shape filter (Whisper model files; directories for the others), - * resolves links, and sorts. Returns an empty list on any error (never - * rejects), so the selector always has something to render. + * List every selectable entry in the engine's models folder, each judged + * `complete` against the catalog in the same pass. Rust applies the per-engine + * shape filter (Whisper model files; directories for the others), resolves + * links, sorts, and stats completeness; the webview owns the catalog (file names + * and expected sizes), so it passes the engine's models in. Returns an empty + * list on any error (never rejects), so the selector always has something to + * render. */ -export async function listModelEntries(engine: Engine): Promise { - const { data } = await commands.listModelEntries(engine); +export async function listModelEntries( + catalog: readonly [LocalModelConfig, ...LocalModelConfig[]], +): Promise { + const { data } = await commands.listModelEntries( + catalog[0].engine, + catalog.map((model) => { + const { filenames, expected } = modelSizeChecks(model); + return { + entryName: modelEntryName(model), + filenames, + expectedSizes: expected, + }; + }), + ); return data ?? []; } @@ -136,26 +151,6 @@ function modelSizeChecks(model: LocalModelConfig): { */ export function createModelStorage(model: LocalModelConfig) { return { - /** - * Whether a valid install exists in the folder. JS passes the catalog's - * expected sizes; Rust resolves the entry through any link, stats each file, - * and returns the completeness verdict (the 90% rule lives in Rust next to - * the stat). One path serves downloaded, linked, and hand-dropped installs, - * so a linked-but-broken model reads as not installed. Never rejects; any - * error reads as not installed. - */ - async isInstalled(): Promise { - const { filenames, expected } = modelSizeChecks(model); - const { data: statuses } = await commands.resolveModelFiles( - model.engine, - modelEntryName(model), - filenames, - expected, - ); - if (!statuses || statuses.length !== expected.length) return false; - return statuses.every((status) => status.complete); - }, - /** * Download the model to its canonical path. Rust stages, integrity-checks * each file, and promotes with one rename; a cancel or error cleans up the diff --git a/apps/whispering/src/lib/settings/transcription-validation.ts b/apps/whispering/src/lib/settings/transcription-validation.ts index dbf2f9c2ae..c50ab3af7d 100644 --- a/apps/whispering/src/lib/settings/transcription-validation.ts +++ b/apps/whispering/src/lib/settings/transcription-validation.ts @@ -62,44 +62,26 @@ export function isTranscriptionServiceConfigured( } export type TranscriptionReadiness = { - service: TranscriptionProviderEntry | undefined; - isServiceAvailable: boolean; - isRuntimeConfigured: boolean; + /** True when the selected service is available here and fully configured. */ isReady: boolean; + /** The single most relevant blocker to show the user, or null when ready. */ primaryIssue: string | null; }; export function getTranscriptionReadiness(): TranscriptionReadiness { const service = getSelectedTranscriptionProvider(); - const isServiceAvailable = service - ? isTranscriptionServiceAvailable(service) - : false; - const isRuntimeConfigured = - service && isServiceAvailable - ? isTranscriptionServiceConfigured(service) - : false; - if (!service) { - return { - service, - isServiceAvailable, - isRuntimeConfigured, - isReady: false, - primaryIssue: 'Choose a transcription service.', - }; + return { isReady: false, primaryIssue: 'Choose a transcription service.' }; } - if (!isServiceAvailable) { + if (!isTranscriptionServiceAvailable(service)) { return { - service, - isServiceAvailable, - isRuntimeConfigured, isReady: false, primaryIssue: `${service.label} is only available in the desktop app.`, }; } - if (!isRuntimeConfigured) { + if (!isTranscriptionServiceConfigured(service)) { const primaryIssue = ( { cloud: `Add your ${service.label} API key.`, @@ -108,20 +90,8 @@ export function getTranscriptionReadiness(): TranscriptionReadiness { } as const )[service.location]; - return { - service, - isServiceAvailable, - isRuntimeConfigured, - isReady: false, - primaryIssue, - }; + return { isReady: false, primaryIssue }; } - return { - service, - isServiceAvailable, - isRuntimeConfigured, - isReady: true, - primaryIssue: null, - }; + return { isReady: true, primaryIssue: null }; } diff --git a/apps/whispering/src/lib/state/local-model-downloads.svelte.ts b/apps/whispering/src/lib/state/local-model-downloads.svelte.ts deleted file mode 100644 index a01abb8946..0000000000 --- a/apps/whispering/src/lib/state/local-model-downloads.svelte.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Shared download state for pre-built local transcription models, keyed by - * engine and model id. Every surface that renders a model (recommended-model - * hero, catalog row) reads the same handle, so a download started in one - * place shows its progress everywhere. - * - * The state machine is computed, not stored: `downloading` while a download - * owns the handle, otherwise disk truth (`isInstalled`) decides between - * not-downloaded and ready. The selected entry name is parent-owned component - * state, so catalog models and custom folder entries activate through the - * same `bind:value` path. - * - * The models folder is user-editable truth (entries can be dropped in or - * deleted outside the app), so `refresh()` re-checks disk; the selector calls - * it from the same window-focus rescan that refreshes folder entries. - * - * Shape mirrors `recordings.svelte.ts`: factory function with `$state` - * closure variables and a return object exposing a reactive getter plus - * operations. - */ -import { Err, Ok, type Result } from 'wellcrafted/result'; -import { - type LocalModelConfig, - modelEntryName, -} from '$lib/constants/local-models'; -import { - createModelStorage, - deleteModelEntry, - type ModelFolderError, -} from '$lib/services/transcription/local-model-folder'; - -type ModelDownloadState = - | { type: 'not-downloaded' } - | { type: 'downloading'; progress: number; cancelling: boolean } - | { type: 'ready' }; - -function createModelDownload(model: LocalModelConfig) { - const storage = createModelStorage(model); - - /** Disk truth: whether a valid install exists in the models folder. */ - let isInstalled = $state(false); - - /** - * The in-flight download attempt, or `null` when idle. This is the re-entry - * gate: `download()` sets it when a run starts and clears it only when that - * same run settles. `cancel()` flips `cancelling` but never clears it, so the - * gate stays closed until the abort actually surfaces — a cancel can never - * reopen the door for a second, overlapping `download_model` call on the same - * partial path. - * - * `id` is unique per attempt, so the Rust registry maps it to exactly one - * transfer for its whole lifetime. `cancelling` gates late progress callbacks - * (so a flushed Channel update cannot repaint after a cancel); the cancel - * itself, including the gap between a multi-file engine's files, is now - * enforced in Rust (the aborted task stops the whole staged download). - */ - let active = $state<{ - id: string; - progress: number; - cancelling: boolean; - } | null>(null); - - /** Per-handle counter; pairs with the model key for a globally unique id. */ - let attempts = 0; - - async function refresh() { - isInstalled = await storage.isInstalled(); - } - - void refresh(); - - return { - /** - * Where this model stands on this device. A getter, not a `$derived`: - * handles are created lazily from whichever component touches them - * first, and a derived created inside a component's effect context - * goes inert when that component is destroyed (`derived_inert`). The - * computation is one state read plus one null check; consumers that - * need caching or narrowing alias it with a component-local `$derived`. - */ - get state(): ModelDownloadState { - if (active) - return { - type: 'downloading', - progress: active.progress, - cancelling: active.cancelling, - }; - return isInstalled ? { type: 'ready' } : { type: 'not-downloaded' }; - }, - - /** - * Re-check disk truth. The models folder can change outside the app - * (entries deleted, a partial download cleaned up), so callers invoke - * this on the same signal they use to rescan the folder, typically - * window focus. - */ - refresh, - - /** - * Download the model, skipping the download when a valid install - * already exists. Selection is owned by the caller's bound value. - */ - async download(): Promise | null> { - if (active) return null; - const id = `${modelDownloadKey(model)}#${++attempts}`; - active = { id, progress: 0, cancelling: false }; - - if (await storage.isInstalled()) { - isInstalled = true; - active = null; - return Ok({ - entryName: modelEntryName(model), - outcome: 'already-installed', - }); - } - - // A cancel that arrived during the install check (before any transfer - // started, so there is no Rust task to abort yet) stops here. Report it - // as a no-op, like an already-in-flight call. - if (active.cancelling) { - active = null; - return null; - } - - const { error } = await storage.download({ - downloadId: id, - // Write through `active` (the $state proxy) so the bar repaints; the - // gate keeps `active` pointing at this attempt for the whole run. - onProgress: (value) => { - if (active && !active.cancelling) active.progress = value; - }, - }); - if (error) { - const wasCancelled = active?.cancelling ?? false; - active = null; - // If we asked to cancel, the abort is what produced this error: a - // clean stop, not a failure. Report it as a no-op (like an - // already-in-flight call) so callers raise no error toast. - return wasCancelled ? null : Err(error); - } - - // Refresh disk truth before releasing the gate so the computed machine - // lands directly on ready. - await refresh(); - active = null; - return Ok({ entryName: modelEntryName(model), outcome: 'downloaded' }); - }, - - /** - * Request cancellation of an in-flight download. Marks the attempt as - * cancelling (the UI shows "Cancelling…") and aborts its transfer in Rust; - * the still-running `download()` drops back to `not-downloaded` and resolves - * to a no-op once the abort surfaces. A no-op when nothing is downloading. - */ - async cancel(): Promise { - if (!active) return; - // Leave `active` set: the owning `download()` clears it when the abort - // surfaces. Until then the gate stays closed, so a re-download cannot - // start a second transfer over this one. - active.cancelling = true; - await storage.cancel(active.id); - }, - - /** Remove the catalog model from disk. Selection is cleared by callers. */ - async delete(): Promise> { - const { error } = await deleteModelEntry({ - engine: model.engine, - name: modelEntryName(model), - }); - if (error) return Err(error); - isInstalled = false; - return Ok(undefined); - }, - }; -} - -/** - * The result of a catalog `download()`: the outcome plus the folder entry - * name to select on success, `Err` on failure, or `null` when the call was a - * no-op (a download was already in flight). - */ -export type ModelDownloadResult = Awaited< - ReturnType['download']> ->; - -function modelDownloadKey(model: LocalModelConfig) { - return `${model.engine}:${model.id}`; -} - -function createLocalModelDownloads() { - const handles = new Map>(); - - return { - /** - * The shared download handle for a catalog model, created on first - * use. Acquire the handle and read its `state` in separate deriveds - * (`$derived(localModelDownloads.get(model))`, then - * `$derived(handle.state)`): a derived does not depend on state it - * created itself, so a single derived that creates the handle and - * reads its state in one expression would never update. - */ - get(model: LocalModelConfig) { - const key = modelDownloadKey(model); - const existing = handles.get(key); - if (existing) return existing; - const handle = createModelDownload(model); - handles.set(key, handle); - return handle; - }, - }; -} - -export const localModelDownloads = createLocalModelDownloads(); diff --git a/apps/whispering/src/lib/state/model-folder.svelte.ts b/apps/whispering/src/lib/state/model-folder.svelte.ts new file mode 100644 index 0000000000..0cc86d5b27 --- /dev/null +++ b/apps/whispering/src/lib/state/model-folder.svelte.ts @@ -0,0 +1,217 @@ +/** + * The single source of truth for an engine's models folder, shared by every + * surface (the first-run hero, the settings list, the catalog rows). + * + * It unifies the two kinds of truth a model selector needs, which are genuinely + * different and both required: + * + * - DISK STATE, at rest: which entries are in the folder, and whether each + * catalog model is complete. Rust owns it; this store projects it via + * `refresh()` (on first use, window focus, and after every mutation it + * performs). The scan and the completeness checks land in ONE cycle, so they + * can never disagree the way a component-local scan and a per-model stat did. + * - IN-FLIGHT TRANSFERS, in motion: downloads underway, with progress and a + * cancel flag. Transient, fed by the download Channel; never on disk yet. You + * cannot fold this into the scan: progress is a running transfer, not a file. + * + * Because the store is a global singleton per engine, a download started + * anywhere updates the one store and every mounted view re-reads reactively: no + * component-local scan to go stale, no per-model handle to drift from the folder. + * Components are pure views. + * + * Selection (the active model name) is deliberately NOT here: it lives in + * deviceConfig and is read by the transcribe dispatcher. Presence (this store) + * and selection (deviceConfig) are different concerns; a selector joins them + * ("is the selected name present?"). + * + * Shape mirrors `recordings.svelte.ts`: a factory with `$state`/`SvelteMap` + * closure variables and a return object exposing reactive getters plus + * operations. + */ +import { SvelteMap } from 'svelte/reactivity'; +import { Err, Ok, type Result } from 'wellcrafted/result'; +import { + type LocalModelConfig, + modelEntryName, +} from '$lib/constants/local-models'; +import { + createModelStorage, + listModelEntries, + type ModelEntry, + type ModelFolderError, +} from '$lib/services/transcription/local-model-folder'; +import { tauri } from '#platform/tauri'; + +type Engine = LocalModelConfig['engine']; + +export type ModelDownloadState = + | { type: 'not-downloaded' } + | { type: 'downloading'; progress: number; cancelling: boolean } + | { type: 'ready' }; + +/** + * The result of a catalog `download()`: the outcome plus the folder entry name + * to select on success, `Err` on failure, or `null` when the call was a no-op + * (a download was already in flight). + */ +export type ModelDownloadResult = Result< + { outcome: 'downloaded' | 'already-installed'; entryName: string }, + ModelFolderError +> | null; + +function createModelFolder(catalog: readonly [LocalModelConfig, ...LocalModelConfig[]]) { + const engine = catalog[0].engine; + + // DISK STATE. `entries` is the folder scan, `null` until the first load so the + // UI can tell "loading" from "empty". Each entry carries its own `complete` + // verdict, judged against the catalog by the one Rust scan, so "ready" is a + // pure read of the scan with no second source to drift from. + let entries = $state(null); + + // IN-FLIGHT TRANSFERS, keyed by model id. Progress re-`set`s the entry + // (SvelteMap tracks `set`, not a nested mutation), so the bar repaints. The + // map IS the re-entry gate: a key present means a transfer owns that model, + // cleared only when that same run settles, so a cancel can never reopen the + // door for a second overlapping `download_model` over the same partial path. + // `id` is unique per attempt, so the Rust registry maps it to exactly one + // transfer; `cancelling` gates late progress callbacks. + const transfers = new SvelteMap< + string, + { id: string; progress: number; cancelling: boolean } + >(); + let attempts = 0; + + async function refresh() { + if (!tauri) return; + // One scan returns each entry already judged complete against the catalog, + // so the listing and the "ready" verdicts are the same data. + entries = await listModelEntries(catalog); + } + + void refresh(); + + function present(name: string): boolean { + return (entries ?? []).some((entry) => entry.name === name); + } + + return { + /** The folder scan: the single disk-state source every view reads. */ + get entries() { + return entries ?? []; + }, + /** Whether the first scan has landed (so "empty" differs from "loading"). */ + get loaded() { + return entries !== null; + }, + /** Whether an entry by this exact name is in the folder right now. */ + present, + /** Folder entries that are not catalog models (bring-your-own / linked). */ + customEntries(): ModelEntry[] { + const names = new Set(catalog.map(modelEntryName)); + return (entries ?? []).filter((entry) => !names.has(entry.name)); + }, + /** Where a catalog model stands: a live transfer, else disk truth. */ + stateOf(model: LocalModelConfig): ModelDownloadState { + const transfer = transfers.get(model.id); + if (transfer) + return { + type: 'downloading', + progress: transfer.progress, + cancelling: transfer.cancelling, + }; + const name = modelEntryName(model); + const entry = (entries ?? []).find((e) => e.name === name); + return entry?.complete + ? { type: 'ready' } + : { type: 'not-downloaded' }; + }, + + /** + * Re-check disk truth. The folder can change outside the app (entries + * dropped in or deleted), so views call this on window focus. + */ + refresh, + + /** + * Download a catalog model, skipping when a valid install already exists. + * Re-scans the folder before releasing the gate so the computed state lands + * directly on `ready`. + */ + async download(model: LocalModelConfig): Promise { + if (transfers.has(model.id)) return null; + const id = `${engine}:${model.id}#${++attempts}`; + transfers.set(model.id, { id, progress: 0, cancelling: false }); + const storage = createModelStorage(model); + const name = modelEntryName(model); + + // Already installed? A fresh scan is the one truth; skip the transfer. + await refresh(); + if ((entries ?? []).find((entry) => entry.name === name)?.complete) { + transfers.delete(model.id); + return Ok({ entryName: name, outcome: 'already-installed' }); + } + + // A cancel that arrived during the install check (before any transfer + // started) stops here, reported as a no-op like an in-flight call. + if (transfers.get(model.id)?.cancelling) { + transfers.delete(model.id); + return null; + } + + const { error } = await storage.download({ + downloadId: id, + onProgress: (progress) => { + const transfer = transfers.get(model.id); + if (transfer && !transfer.cancelling) + transfers.set(model.id, { ...transfer, progress }); + }, + }); + if (error) { + const wasCancelled = transfers.get(model.id)?.cancelling ?? false; + transfers.delete(model.id); + // A requested cancel is the cause of this error: a clean stop, not a + // failure. Report it as a no-op so callers raise no error toast. + return wasCancelled ? null : Err(error); + } + + await refresh(); + transfers.delete(model.id); + return Ok({ entryName: name, outcome: 'downloaded' }); + }, + + /** + * Request cancellation of an in-flight download. Marks it cancelling (the UI + * shows "Cancelling…") and aborts its transfer in Rust; the still-running + * `download()` drops back to `not-downloaded` once the abort surfaces. + * Leaves the gate closed until then, so a re-download cannot start a second + * transfer over this one. A no-op when nothing is downloading. + */ + async cancel(model: LocalModelConfig): Promise { + const transfer = transfers.get(model.id); + if (!transfer) return; + transfers.set(model.id, { ...transfer, cancelling: true }); + await createModelStorage(model).cancel(transfer.id); + }, + }; +} + +const folders = new Map>(); + +/** + * The shared `modelFolder` for an engine, created on first use from its catalog. + * Pass the engine's catalog models (e.g. `PARAKEET_MODELS`); the same engine + * always passes the same constant, so this is a singleton per engine. + */ +export function modelFolder( + catalog: readonly [LocalModelConfig, ...LocalModelConfig[]], +) { + const engine = catalog[0].engine; + const existing = folders.get(engine); + if (existing) return existing; + const folder = createModelFolder(catalog); + folders.set(engine, folder); + return folder; +} + +/** The shared folder store handle, for components that receive it as a prop. */ +export type ModelFolder = ReturnType; diff --git a/apps/whispering/src/lib/tauri/bindings.gen.ts b/apps/whispering/src/lib/tauri/bindings.gen.ts index 9bbeffe5ed..7a0112201a 100644 --- a/apps/whispering/src/lib/tauri/bindings.gen.ts +++ b/apps/whispering/src/lib/tauri/bindings.gen.ts @@ -222,11 +222,14 @@ export const commands = { * 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. */ - listModelEntries: (engine: Engine) => + listModelEntries: (engine: Engine, catalog: CatalogModel[]) => typedError( - __TAURI_INVOKE('list_model_entries', { engine }), + __TAURI_INVOKE('list_model_entries', { engine, catalog }), ), /** * Remove one entry from the engine's models folder. A symlinked entry removes @@ -239,16 +242,12 @@ export const commands = { __TAURI_INVOKE('delete_model_entry', { engine, name }), ), /** - * 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). */ resolveModelFiles: ( engine: Engine, @@ -372,6 +371,20 @@ export const events = { }; /* Types */ +/** + * 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`. + */ +export type CatalogModel = { + /** The model's folder entry name, matched against scanned entry names. */ + entryName: string; + filenames: string[]; + expectedSizes: (number | null)[]; +}; + /** * One command's binding, as sent from the FE registrar. `command_id` is the * id the trigger event is emitted under; the FE filters by that command's `on` @@ -610,6 +623,13 @@ export type ModelEntry = { * only ("Your model (linked)"); it does not change how the entry loads. */ linked: boolean; + /** + * 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. + */ + complete: boolean; }; /** @@ -630,9 +650,8 @@ export type ModelFileDownload = { /** * 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`. */ export type ModelFileStatus = { /** diff --git a/apps/whispering/src/lib/tauri/commands.ts b/apps/whispering/src/lib/tauri/commands.ts index 98e22b7b99..2bb2e7e3a5 100644 --- a/apps/whispering/src/lib/tauri/commands.ts +++ b/apps/whispering/src/lib/tauri/commands.ts @@ -114,7 +114,6 @@ export type { LocalModelState, ModelEntry, ModelFileDownload, - ModelFileStatus, ModelFolderError, ModelImportError, ModelStateEvent, diff --git a/apps/whispering/src/routes/(app)/(config)/settings/transcription/+page.svelte b/apps/whispering/src/routes/(app)/(config)/settings/transcription/+page.svelte index f66d8e60f4..5d6ff9dab8 100644 --- a/apps/whispering/src/routes/(app)/(config)/settings/transcription/+page.svelte +++ b/apps/whispering/src/routes/(app)/(config)/settings/transcription/+page.svelte @@ -1,6 +1,8 @@ Transcription Settings - Whispering @@ -12,5 +14,9 @@ hints work. + settings.get('transcription.service'), + (selected) => settings.set('transcription.service', selected)} + /> diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index 45d18a73cb..a22ca6da1e 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -7,21 +7,12 @@ import * as SectionHeader from '@epicenter/ui/section-header'; import * as ToggleGroup from '@epicenter/ui/toggle-group'; import XIcon from '@lucide/svelte/icons/x'; - import { createQuery } from '@tanstack/svelte-query'; import type { UnlistenFn } from '@tauri-apps/api/event'; import { onDestroy, onMount } from 'svelte'; import { defineErrors, extractErrorMessage } from 'wellcrafted/error'; import { tryAsync } from 'wellcrafted/result'; import { commandCallbacks } from '$lib/commands'; import DictationCapabilityNotice from '$lib/components/DictationCapabilityNotice.svelte'; - import TranscriptDialog from '$lib/components/copyable/TranscriptDialog.svelte'; - import { - TranscriptionRuntimeConfig, - TranscriptionSelector, - TransformationSelector, - } from '$lib/components/settings'; - import ManualDeviceSelector from '$lib/components/settings/selectors/ManualDeviceSelector.svelte'; - import VadDeviceSelector from '$lib/components/settings/selectors/VadDeviceSelector.svelte'; import { CAPTURE_SURFACE_META, CAPTURE_SURFACE_OPTIONS, @@ -37,34 +28,32 @@ import { importFiles } from '$lib/operations/import'; import { selectCaptureSurface } from '$lib/operations/recording'; import { report } from '$lib/report'; - import { rpc } from '$lib/rpc'; import { services } from '$lib/services'; import { getTranscriptionReadiness } from '$lib/settings/transcription-validation'; import { captureSurface } from '$lib/state/capture-surface.svelte'; import { dictationCapability } from '$lib/state/dictation-capability.svelte'; import { manualRecorder } from '$lib/state/manual-recorder.svelte'; import { recordings } from '$lib/state/recordings.svelte'; - import { settings } from '$lib/state/settings.svelte'; import { getRecordingShortcutLabel } from '$lib/utils/recording-shortcut'; - import { viewTransition } from '$lib/utils/viewTransitions'; import studioMicrophone from '$lib/assets/studio-microphone.png'; import { tauri } from '#platform/tauri'; - import CaptureBehaviorPopover from './_components/CaptureBehaviorPopover.svelte'; import CapturePipeline from './_components/CapturePipeline.svelte'; + import FirstRunSetup from './_components/FirstRunSetup.svelte'; import ManualRecordingAction from './_components/ManualRecordingAction.svelte'; + import RecordingResult from './_components/RecordingResult.svelte'; import VadRecordingAction from './_components/VadRecordingAction.svelte'; const latestRecording = $derived(recordings.sorted[0]); - const transcriptionReadiness = $derived(getTranscriptionReadiness()); - // The selected transformation's pipeline glyph morphs into that - // transformation's row on the /transformations list, so name it with the same - // id the row carries. Only the home pipeline opts in; the config topbar leaves - // its selector unnamed so it never collides with the rows on /transformations. - const transformationViewTransitionName = $derived( - viewTransition.transformation( - settings.get('transformation.selectedId') ?? null, - ), - ); + // First-run setup is "active" from mount whenever transcription isn't ready; + // the only in-mount transition is the user finishing it (onComplete sets it + // false). The initial value is computed once here, synchronously, so there is + // no first-paint flash and no $effect latch, and no persisted "seen + // onboarding" flag (which would drift from readiness). It never needs to flip + // back to true within a mount because nothing on the recorder screen can make + // you un-ready; a regression (model deleted in settings) re-activates it for + // free, because SvelteKit remounts this page on navigation back to home. So + // first run and a later regression show the same flow. + let setupActive = $state(!getTranscriptionReadiness().isReady); // The recording shortcut that actually fires on this platform, via the // `#platform/shortcuts` label seam: desktop binds push-to-talk (Fn) globally // and ships the toggle unbound, so prefer it; the browser shows the local @@ -97,11 +86,6 @@ }), }); - const audioPlaybackUrlQuery = createQuery(() => ({ - ...rpc.audio.getPlaybackUrl(() => latestRecording?.id ?? '').options, - enabled: !!latestRecording?.id, - })); - let unlistenDragDrop: UnlistenFn | undefined; onMount(async () => { @@ -171,18 +155,13 @@ onDestroy(() => { unlistenDragDrop?.(); - if (latestRecording?.id) { - services.blobs.audio.revokeUrl(latestRecording.id); - } }); Whispering -
- +{#snippet hero()} +
- + Press shortcut → speak → get text. Free and open source ❤️
+{/snippet} - - - {#if !transcriptionReadiness.isReady} -
-
-

Set up transcription

-

- {transcriptionReadiness.primaryIssue ?? - 'Choose how Whispering turns your speech into text.'} -

-
- -
- {:else} - captureSurface.current, - (surface) => { - if (!surface) return; - void selectCaptureSurface(surface as CaptureSurface); - }} - class="w-full" - > - {#each CAPTURE_SURFACE_OPTIONS as option} - {@const SurfaceIcon = CAPTURE_SURFACE_META[option.value].Icon} - - - - - {/each} - - +
+ {#if setupActive} - {#if captureSurface.current === 'manual'} -
- - {#snippet pipeline()} - - - - - - - {/snippet} - - {#if manualRecorder.state === 'RECORDING'} - - {/if} -
- {:else if captureSurface.current === 'vad'} -
- - {#snippet pipeline()} - - - - - - - {/snippet} - -
- {:else if captureSurface.current === 'import'} -
- { - if (files.length > 0) { - await importFiles({ files }); - } - }} - onFileRejected={({ file, reason }) => { - report.error({ - cause: PageError.FileRejected({ - fileName: file.name, - reason, - }).error, - title: 'File rejected', - }); + (setupActive = false)} /> + {:else} +
+ {@render hero()} + + + captureSurface.current, + (surface) => { + if (!surface) return; + void selectCaptureSurface(surface as CaptureSurface); }} - class="h-32 sm:h-36 w-full" - /> - - - + {#each CAPTURE_SURFACE_OPTIONS as option} + {@const SurfaceIcon = CAPTURE_SURFACE_META[option.value].Icon} + + + + + {/each} + + + {#if captureSurface.current === 'manual'} +
+ + {#if manualRecorder.state === 'RECORDING'} + + {/if} +
+ {:else if captureSurface.current === 'vad'} +
+ +
+ {:else if captureSurface.current === 'import'} +
+ { + if (files.length > 0) { + await importFiles({ files }); + } + }} + onFileRejected={({ file, reason }) => { + report.error({ + cause: PageError.FileRejected({ + fileName: file.name, + reason, + }).error, + title: 'File rejected', + }); + }} + class="h-32 sm:h-36 w-full" /> - -
- {/if} + +
+ {/if} - {#if latestRecording} -
- { confirmationDialog.open({ title: 'Delete recording', @@ -352,77 +280,66 @@ }); }} /> + {/if} - {#if audioPlaybackUrlQuery.data} - +
+ {#if captureSurface.current === 'manual'} +

+ {#if manualShortcutLabel} + Click the microphone to record{tauri ? ' here' : ''}, or press + + {manualShortcutLabel} + + {tauri ? 'to record from anywhere.' : 'to record.'} + {:else if tauri} + Click the microphone to record, or + set a global shortcut + to record from anywhere. + {:else} + Click the microphone to start recording. + {/if} +

+ {:else if captureSurface.current === 'vad'} +

+ {#if vadShortcutLabel} + Click the microphone to listen{tauri ? ' here' : ''}, or press + + {vadShortcutLabel} + + {tauri ? 'to listen from anywhere.' : 'to listen.'} + {:else if tauri} + Click the microphone to start a voice activated session, or + set a global shortcut + to listen from anywhere. + {:else} + Click the microphone to start a voice activated session. + {/if} +

{/if} -
- {/if} - -
- {#if captureSurface.current === 'manual'} -

- {#if manualShortcutLabel} - Click the microphone to record{tauri ? ' here' : ''}, or press - - {manualShortcutLabel} - - {tauri ? 'to record from anywhere.' : 'to record.'} - {:else if tauri} - Click the microphone to record, or - set a global shortcut - to record from anywhere. - {:else} - Click the microphone to start recording. - {/if} -

- {:else if captureSurface.current === 'vad'} -

- {#if vadShortcutLabel} - Click the microphone to listen{tauri ? ' here' : ''}, or press +

+ {#if !tauri} + Tired of switching tabs? - {vadShortcutLabel} + Get the native desktop app - {tauri ? 'to listen from anywhere.' : 'to listen.'} - {:else if tauri} - Click the microphone to start a voice activated session, or - set a global shortcut - to listen from anywhere. - {:else} - Click the microphone to start a voice activated session. {/if}

- {/if} -

- {#if !tauri} - Tired of switching tabs? - - Get the native desktop app - - {/if} -

+
{/if}
diff --git a/apps/whispering/src/routes/(app)/_components/CapturePipeline.svelte b/apps/whispering/src/routes/(app)/_components/CapturePipeline.svelte index 18d93ee583..5e60cb4c3d 100644 --- a/apps/whispering/src/routes/(app)/_components/CapturePipeline.svelte +++ b/apps/whispering/src/routes/(app)/_components/CapturePipeline.svelte @@ -1,23 +1,46 @@ -
- {@render children()} + {#if children} + {@render children()} + {/if} + + + {#if children} + + {/if}
diff --git a/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte b/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte new file mode 100644 index 0000000000..3b8c010adf --- /dev/null +++ b/apps/whispering/src/routes/(app)/_components/FirstRunSetup.svelte @@ -0,0 +1,590 @@ + + + +{#snippet panelHead(Icon: Component, title: string, desc: string)} +
+ +
+
+

{title}

+

+ {desc} +

+
+{/snippet} + +
+ {#if showWelcome} + +
+
+ +
+

+ Welcome to Whispering +

+

+ Press your shortcut, speak, and your words turn into text. +

+
+
+ +
+ {#each TRUST_POINTS as point} + {@const Icon = point.Icon} + + + + + + {point.title} + {point.description} + + + {/each} +
+ + +
+ {:else} +
+ +
+ {#each steps as step, i} + {@const status = statusOf(i)} +
+
+ {#if status === 'done'} + + {:else} + {i + 1} + {/if} +
+ {step.label} +
+ {#if i < steps.length - 1} +
+
+
+ {/if} + {/each} +
+ + +
+ {#key transitionKey} +
+ {#if done} +
+
+ +
+
+

+ {dictationCapability.isActive + ? "You're ready to dictate anywhere" + : "You're ready to record in Whispering"} +

+

+ {dictationCapability.isActive + ? 'Press your shortcut in any app, speak, and your words appear.' + : 'Click the microphone on the home screen and speak. You can turn on dictate-anywhere later.'} +

+
+ {#if dictationCapability.isActive && shortcutLabel} +
+ Try it now: + {shortcutLabel} +
+ {/if} + +
+ {:else if current?.key === 'engine'} +
+
+

+ Set up your voice engine +

+

+ {transcriptionReadiness.primaryIssue ?? + 'Choose where transcription runs. You can change it anytime.'} +

+
+ + {#if tauri} +
+ {#each ENGINE_LOCATIONS as location (location.value)} + {@const Icon = location.Icon} + {@const selected = currentLocation === location.value} + + {/each} +
+ + +
+ +
+ {#if currentLocation === 'cloud'} + settings.get('transcription.service'), + (selected) => + settings.set('transcription.service', selected)} + /> + {/if} + +
+
+ {:else} + + settings.get('transcription.service'), + (selected) => + settings.set('transcription.service', selected)} + /> + + {/if} +
+ {:else if current?.key === 'try'} +
+ +
+

+ Try your first dictation +

+

+ Click record and say anything. Whispering turns it into text. +

+
+ + + {#if practiceRecording} + +
+ {#if practiceTranscript} +
+ It works +
+ {/if} + + {#if practiceTranscript} +

+ Copied to your clipboard. Every recording works this way. +

+ {/if} +
+ {/if} +
+ {:else} + + + {@render panelHead( + ShieldCheck, + 'Want to dictate in every app?', + 'To type into any other app (your editor, browser, chat) with a global shortcut, macOS needs Accessibility access. It is optional, and you can turn it on anytime.', + )} + + {#if dictationCapability.isActive} + + Accessibility is on + + {:else} + +

+ Opens a short guide. We detect it automatically once it's on. +

+ {/if} +
+
+ {/if} +
+ {/key} +
+ + + {#if !done} +
+ + {#if current?.key === 'try' && !practiceTranscript} + + {:else if current?.key === 'access' && !dictationCapability.isActive} + + {:else} + + {/if} +
+ {/if} +
+ {/if} +
+ + diff --git a/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte b/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte index bbd60008cb..967e95cf85 100644 --- a/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte +++ b/apps/whispering/src/routes/(app)/_components/ManualRecordingAction.svelte @@ -1,75 +1,22 @@ +> + {#snippet footer()} + + + + {/snippet} + diff --git a/apps/whispering/src/routes/(app)/_components/RecordingActionCard.svelte b/apps/whispering/src/routes/(app)/_components/RecordingActionCard.svelte index 9a0a38490c..2ccec076d2 100644 --- a/apps/whispering/src/routes/(app)/_components/RecordingActionCard.svelte +++ b/apps/whispering/src/routes/(app)/_components/RecordingActionCard.svelte @@ -3,28 +3,20 @@ import * as Kbd from '@epicenter/ui/kbd'; import { Spinner } from '@epicenter/ui/spinner'; import { cn } from '@epicenter/ui/utils'; - import type { Component, Snippet } from 'svelte'; + import type { Snippet } from 'svelte'; import { dictationCapability } from '$lib/state/dictation-capability.svelte'; + import type { RecordingActionController } from './recording-action-controller'; - // The caller owns its own state machine, so it picks which icon to show - // and hands us one `icon`. We only decide presentation: a spinner while - // pending, and the destructive "filled" treatment while `active`. + // The controller owns the state machine and every derived label/icon. The card + // only decides presentation: a spinner while pending, the destructive "filled" + // treatment while active, and a footer shown only at rest. let { - active = false, - description, + controller, footer, - icon: Icon, iconViewTransitionName, - label, - onclick, - pending = false, - shortcutLabel, - tooltip, }: { - active?: boolean; - description: string; + controller: RecordingActionController; footer?: Snippet; - icon: Component<{ class?: string }>; /** * When set, names the action glyph for a cross-page view transition while * the card is at rest. Suppressed automatically while `active`, because the @@ -33,67 +25,71 @@ * the card owns the at-rest gate. */ iconViewTransitionName?: string; - label: string; - onclick: () => void; - pending?: boolean; - shortcutLabel?: string; - tooltip: string; } = $props(); const accessibleLabel = $derived( - shortcutLabel ? `${label} (${shortcutLabel})` : label, + controller.shortcutLabel + ? `${controller.label} (${controller.shortcutLabel})` + : controller.label, );