diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 7a8ca99..25f316a 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -17,7 +17,8 @@ import { toasts } from "$lib/stores/toasts.svelte.js"; import { clampTextLines } from "$lib/utils/textMeasure.js"; import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; - import { Check, ChevronRight, Search, X } from "lucide-svelte"; + import { Check, Search, X, Mic, BookOpen, Type, Sparkles, SquareCheck, Clipboard, Sliders } from "lucide-svelte"; + import { formatDuration } from "$lib/utils/time.js"; import { _ } from "svelte-i18n"; import { SUPPORTED_LOCALES, setLocale, currentLocale } from "$lib/i18n"; // Aliased because SettingsPage has its own local refreshLlmStatus @@ -40,6 +41,11 @@ }); let downloadingModel = $state(""); let downloadProgress = $state(0); + // Whisper download. bytes and start time drive the byte counter and the + // rough remaining-time estimate alongside the percent. + let downloadBytes = $state(0); + let downloadTotal = $state(0); + let downloadStartedAt = $state(0); let unlisten = null; let unlistenLlm = null; let outputFolderEl = $state(null); @@ -48,9 +54,36 @@ let llmStatus = $state("Checking..."); let llmDownloadingModel = $state(""); let llmDownloadProgress = $state(0); + // LLM download mirrors the whisper telemetry. Bytes + start time give + // us the same bytes / ETA chip that ships on the whisper row. + let llmDownloadBytes = $state(0); + let llmDownloadTotal = $state(0); + let llmDownloadStartedAt = $state(0); let llmLoaded = $state(false); let systemInfo = $state(null); + // Bytes formatter shared with the model download chips. Mirrors the + // copy used inside ModelDownloader.svelte; lifted here so we don't have + // to import a single component just for one helper. + function formatBytes(bytes: number) { + if (!bytes || bytes < 0) return "0 B"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`; + return `${(bytes / 1073741824).toFixed(2)} GB`; + } + + // Estimated seconds remaining from a percent + the moment the download + // started. Returns 0 until we have at least 1% of progress so the first + // tick doesn't read as "300 minutes". Mirrors the FirstRunPage shape + // but exported here so we can show it on the Whisper and LLM rows. + function etaSecondsFromPercent(percent: number, startedAt: number): number { + if (!percent || percent <= 0 || !startedAt) return 0; + const elapsedSec = (Date.now() - startedAt) / 1000; + const totalSec = elapsedSec / (percent / 100); + return Math.max(0, totalSec - elapsedSec); + } + const LLM_MODELS = [ { id: "qwen3_5_2b", @@ -389,8 +422,6 @@ let unlistenParakeet = null; // Profiles & Templates - let showProfiles = $state(false); - let showTemplates = $state(false); let editingProfile = $state(-1); let editingTemplate = $state(-1); let newProfileName = $state(""); @@ -543,6 +574,9 @@ const modelId = selectedLlmModelId(); llmDownloadingModel = modelId; llmDownloadProgress = 0; + llmDownloadBytes = 0; + llmDownloadTotal = 0; + llmDownloadStartedAt = Date.now(); llmStatus = "Downloading..."; try { await invoke("download_llm_model", { modelId }); @@ -708,10 +742,12 @@ // Phase 5 rituals. Autostart state mirrors the OS-level entry managed // by tauri-plugin-autostart; reading via invoke keeps the toggle // honest even if the user has edited their .desktop file manually. - let autostartSyncing = $state(false); - + // + // Async onChange handler for the Launch-at-Login toggle. Toggle.svelte + // shows a spinner while the promise is in flight and snaps back to the + // previous checked value on rejection, so we throw on failure rather + // than swallow it. The store value is only mutated on success. async function setLaunchAtLogin(nextOn: boolean) { - autostartSyncing = true; try { const plugin = await import("@tauri-apps/plugin-autostart"); if (nextOn) { @@ -722,13 +758,13 @@ settings.launchAtLogin = nextOn; } catch (err) { toasts.warn("Could not update autostart", String(err)); - // Re-read to correct the UI if we failed halfway. + // Re-read to correct the store if we failed halfway, then re-throw + // so Toggle can snap its checked state back to the previous value. try { const plugin = await import("@tauri-apps/plugin-autostart"); settings.launchAtLogin = await plugin.isEnabled(); } catch { /* best-effort */ } - } finally { - autostartSyncing = false; + throw err; } } @@ -790,11 +826,18 @@ unlisten = await listen("model-download-progress", (event) => { downloadProgress = event.payload.percent || event.payload.progress || 0; + // The Rust side sends snake_case keys via DownloadProgress; the + // older tiny/base ModelDownloader path uses `downloaded`/`total`. + // Read both so the chip works regardless of which path emitted. + downloadBytes = event.payload.bytes_downloaded ?? event.payload.downloaded ?? 0; + downloadTotal = event.payload.total_bytes ?? event.payload.total ?? 0; }); unlistenLlm = await listen("magnotia:llm-download-progress", (event) => { llmDownloadProgress = event.payload.percent || 0; llmDownloadingModel = event.payload.modelId || llmDownloadingModel; + llmDownloadBytes = event.payload.done ?? 0; + llmDownloadTotal = event.payload.total ?? 0; }); unlistenParakeet = await listen("parakeet-download-progress", (event) => { @@ -842,6 +885,9 @@ async function downloadModel(size) { downloadingModel = size; downloadProgress = 0; + downloadBytes = 0; + downloadTotal = 0; + downloadStartedAt = Date.now(); try { await invoke("download_model", { size }); downloadedModels = await invoke("list_models"); @@ -980,7 +1026,15 @@ solid bg-bg matches the page so groups don't bleed under it. -->
-

Settings

+
+

Settings

+ + 100% local · no telemetry · no cloud +